Pin projects in Claude Desktop's Code tab: a Projects pane with the pinned ones on top, and a tab above the prompt for each.

A Projects pane with your pinned projects on top, live session states like the sidebar's dots,<br> and a tab above the prompt for every project you pin.
<img src="docs/images/hero-light.svg" width="100%" alt="Claude Desktop's Code tab with the env-tabs Projects pane docked on the right, pinned projects on top with session dots and counts, and project tabs above the prompt">
<sub>Demo projects. The pictures are drawn by <a href="docs/demo/render.mjs">docs/demo/render.mjs</a> with the mod's own icons and colors.</sub>
The Code tab's sidebar sorts its folder groups by itself, by recent activity or A to Z, and there is no way to keep the projects you use most at the top. Plenty of people have asked for one, on Windows and on macOS: #70104, #66196, #102088.
Neither a mod nor Claude can pin a folder group in the app's sidebar, so env-tabs draws a Projects pane of its own beside the conversation, and tabs above the prompt. Your sessions are not moved or filed anywhere: the app's sidebar stays exactly as it is.
<img src="docs/images/pane-light.svg" width="760" alt="The Projects pane: pinned projects on top with icons, counts and session dots, the other recent projects below, a row showing + New and Pin under the pointer, and This computer at the bottom">
| State | What it means | |
|---|---|---|
| <img src="docs/images/dot-waiting.svg" width="14" alt="amber dot"> | Waiting for you | The session needs a permission or an answer. |
| <img src="docs/images/dot-unread.svg" width="14" alt="blue dot"> | Unread | It finished after you last looked at it. |
| <img src="docs/images/dot-running.svg" width="14" alt="grey dot"> | Running | Claude is working; the dot pulses. |
| <img src="docs/images/dot-idle.svg" width="14" alt="grey ring"> | Idle | Nothing new. |
A project's row and its tab count the sessions that need you: the ones waiting for you or unread, in amber while one waits. A project with a session running and nothing to count shows the running dot. The states refresh every few seconds, and the session you are in is never shown as unread.
<img src="docs/images/tabs-light.svg" width="100%" alt="Project tabs above the prompt: orbit-api highlighted with an amber count of 2, pixel-forge with a blue 1, northwind-web running, atlas-cli, and a Projects button">
At the prompt of a terminal session:
/plugin install env-tabs --marketplace ahmtsahin/claude-env-tabs
Or from a shell:
claude plugin install env-tabs --marketplace ahmtsahin/claude-env-tabs
Answer y to add the marketplace and pick the user scope, so the pane and the tabs show in every session.
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with ahmtsahin/claude-env-tabs, then Add plugin.
[!NOTE] Requires Claude Desktop with mod support. Built and tested on Windows 11 with Claude Desktop (Claude Code engine 2.1.299). macOS runs the same code, opening sessions with
openinstead ofrundll32and reading the session states from~/Library/Application Support/Claude; the tests cover both, but it has not been tried on a Mac yet.
/env-tabs panel. Close it and it stays closed in later sessions until you open it again.| Command | What it does |
|---|---|
/env-tabs | Lists the pinned projects |
/env-tabs pin [folder] | Pins this session's project, or the folder given |
/env-tabs unpin <n> | Unpins tab n (or the tab with that label) |
/env-tabs move <n> <to> | Moves tab n to position to, in the pane too |
/env-tabs rename <n> <label> | Renames tab n and its project in the pane |
/env-tabs panel | Opens the Projects pane |
/env-tabs status | Shows what env-tabs sees, and where it reads the states from |
D:\Code does not gather D:\Code\api under it. Pin that one too.claude://code/new?folder=.... The app asks you to confirm the folder each time (Trust workspace), since it does not trust a folder that comes from a link. Now and then it still starts the session in a new scratch folder; its Environment card then shows Choose folder…, so choose the folder there before you send the first message.Everything stays on your computer: env-tabs makes no network requests.
| What | Why | |
|---|---|---|
| Reads | The app's session list, through the app's own local tools | The projects and their sessions |
| Reads | Each running session's file in ~/.claude/sessions | Busy, idle, or waiting for you |
| Reads | The desktop's record of each session | When you last looked at it, when it last did something |
| Runs | rundll32 url.dll,FileProtocolHandler claude://… on Windows, open claude://… on macOS | Opening a session, or starting one in a folder |
| Keeps | Your pins, and which projects you folded, in the plugin's own store | So they are there in the next session |
claude plugin validate .
claude plugin test .
To run a working copy, start a session with claude --plugin-dir <this folder>, or name the folder in CLAUDE_CODE_PLUGIN_DIRS. The pictures in this README are drawn by node docs/demo/render.mjs.
Issues and pull requests are welcome.
If env-tabs saves you a few clicks a day, a ⭐ helps other people find it.
hooks/register.tsx 1340 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import type { Pin, SessionRow, SessionState } from '../types'
5
6const pins = atom({ plugin: 'env-tabs', key: 'pins' } as const, [])
7const sessions = atom({ plugin: 'env-tabs', key: 'sessions' } as const, [])
8const thisSession = atom({ plugin: 'env-tabs', key: 'thisSession' } as const, null)
9const here = atom({ plugin: 'env-tabs', key: 'here' } as const, '')
10const flipped = atom({ plugin: 'env-tabs', key: 'flipped' } as const, [])
11const states = atom({ plugin: 'env-tabs', key: 'states' } as const, {})
12const probe = atom({ plugin: 'env-tabs', key: 'probe' } as const, null)
13
14const STORE_KEY = 'pins'
15const FLIPPED_KEY = 'flipped'
16const CLOSED_KEY = 'paneClosed'
17const REFRESH_MS = 60_000
18// How often the sessions' states are read again: a session waiting on the
19// person should show soon.
20const STATES_MS = 4_000
21// The engine caps an MCP answer's size, so a long session list is asked for
22// in a smaller size when the larger one is refused.
23const LIST_LIMITS = [60, 30, 15]
24const TOO_LARGE = /exceeds maximum allowed tokens/i
25const TOOL = 'mcp__env-tabs__tabs'
26const PANE = 'projects'
27const PANE_TITLE = 'Projects'
28// The most sessions an open project lists in the pane.
29const SHOWN = 5
30
31const HELP = [
32 'Usage: /env-tabs [list | pin [folder] | unpin <n> | move <n> <to> | rename <n> <label> | panel | status]',
33 " pin pins this session's project (or the folder given): a tab, and a place at the top of the Projects pane",
34 ' unpin removes tab <n> (or the tab with that label)',
35 ' move moves tab <n> to position <to>',
36 ' rename sets the label of tab <n>',
37 ' panel opens the Projects pane: the pinned projects first, then the rest, each with its sessions',
38 ' status shows what the tabs see',
39].join('\n')
40
41// Paths: Windows paths compare without case and with either slash.
42
43const isWindowsPath = (path: string) =>
44 /^[A-Za-z]:([\\/]|$)/.test(path) || path.startsWith('\\\\')
45
46const normalize = (path: string) => {
47 const trimmed = path.trim()
48
49 return isWindowsPath(trimmed)
50 ? trimmed.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()
51 : trimmed.replace(/\/+$/, '')
52}
53
54const baseName = (path: string) =>
55 path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
56
57// Projects as the sidebar groups sessions: a session belongs to the folder it
58// runs in, so a folder inside a pinned one is a project of its own. A session
59// in one of a repository's worktrees belongs to the repository.
60const WORKTREE = /[\\/]\.claude[\\/]worktrees([\\/].*)?$/i
61
62// The app runs a session started without a folder in a scratch folder it makes
63// for that session; all of them are one project, This computer.
64const SCRATCH = /[\\/]Claude[\\/]scratch-workspaces[\\/]/i
65const SCRATCH_KEY = 'scratch'
66const SCRATCH_LABEL = 'This computer'
67
68const isScratch = (folder: string) => SCRATCH.test(folder)
69
70const projectFolder = (folder: string) => folder.replace(WORKTREE, '')
71
72/** The project a folder belongs to, by key. */
73const keyOf = (folder: string) =>
74 isScratch(folder) ? SCRATCH_KEY : normalize(projectFolder(folder))
75
76/** The pin of a folder's project; a scratch folder has none. */
77const pinOf = (folder: string, list: readonly Pin[]) =>
78 isScratch(folder) ? undefined : list.find(pin => keyOf(pin.folder) === keyOf(folder))
79
80// State and store. Each write skips a value that has not changed, so the band
81// and the pane redraw only on news.
82
83const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
84
85const setPins = async ($: EngineInterface, list: Pin[]) => {
86 if (!same(await read($, pins), list)) {
87 await update($, pins, () => list)
88 }
89}
90
91const setSessions = async ($: EngineInterface, rows: SessionRow[]) => {
92 if (!same(await read($, sessions), rows)) {
93 await update($, sessions, () => rows)
94 }
95}
96
97const setThisSession = async ($: EngineInterface, row: SessionRow | null) => {
98 if (!same(await read($, thisSession), row)) {
99 await update($, thisSession, () => row)
100 }
101}
102
103const setHere = async ($: EngineInterface, folder: string) => {
104 if ((await read($, here)) !== folder) {
105 await update($, here, () => folder)
106 }
107}
108
109const setFlipped = async ($: EngineInterface, keys: string[]) => {
110 if (!same(await read($, flipped), keys)) {
111 await update($, flipped, () => keys)
112 }
113}
114
115const setStates = async ($: EngineInterface, value: Record<string, SessionState>) => {
116 if (!same(await read($, states), value)) {
117 await update($, states, () => value)
118 }
119}
120
121const toPins = (value: unknown): Pin[] => {
122 if (!Array.isArray(value)) {
123 return []
124 }
125
126 return value.flatMap(item => {
127 const pin = item as Partial<Pin> | null
128
129 return typeof pin?.folder === 'string' && typeof pin.label === 'string'
130 ? [{ folder: pin.folder, label: pin.label }]
131 : []
132 })
133}
134
135const toKeys = (value: unknown): string[] =>
136 Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
137
138const loadPins = async ($: EngineInterface) => toPins(await $.store.get(STORE_KEY))
139
140const savePins = async ($: EngineInterface, list: Pin[]) => {
141 await $.store.set(STORE_KEY, list)
142 await setPins($, list)
143}
144
145/**
146 * Flips a project in the pane: open where it starts closed, closed where it
147 * starts open. A pinned project starts open, any other closed.
148 */
149const setFlip = async ($: EngineInterface, key: string, isFlipped?: boolean) => {
150 const keys = toKeys(await $.store.get(FLIPPED_KEY))
151 const wanted = isFlipped ?? !keys.includes(key)
152 const changed = keys.filter(one => one !== key).concat(wanted ? [key] : [])
153 await $.store.set(FLIPPED_KEY, changed)
154 await setFlipped($, changed)
155}
156
157// The desktop app's own tools, reached through its MCP servers.
158
159const textOf = (blocks: readonly { text?: string }[]) =>
160 blocks.map(block => block.text ?? '').join('')
161
162const parseJson = (text: string): unknown => {
163 try {
164 return JSON.parse(text)
165 } catch {
166 return undefined
167 }
168}
169
170/** Calls one of the app's tools: its text answer, or a throw with the app's reason. */
171const callApp = async (
172 $: EngineInterface,
173 server: string,
174 tool: string,
175 args: Record<string, unknown>,
176) => {
177 const answer = await $.mcp.call(server, tool, args)
178 const text = textOf(answer.content)
179 if (answer.isError) {
180 throw new Error(text)
181 }
182
183 return text
184}
185
186type ListedSession = {
187 sessionId?: string
188 title?: string
189 cwd?: string
190 link?: string
191 isRunning?: boolean
192 lastActivityAt?: string
193}
194
195const toRow = (item: unknown): SessionRow | undefined => {
196 const row = item as ListedSession | null
197
198 return typeof row?.sessionId === 'string' && typeof row.cwd === 'string'
199 ? {
200 id: row.sessionId,
201 title: row.title ?? '',
202 folder: row.cwd,
203 link: row.link ?? null,
204 isRunning: row.isRunning === true,
205 lastActivityAt: row.lastActivityAt ?? '',
206 }
207 : undefined
208}
209
210const toRows = (text: string): SessionRow[] => {
211 const parsed = parseJson(text)
212 if (!Array.isArray(parsed)) {
213 throw new Error(`list_sessions answered: ${text.slice(0, 200)}`)
214 }
215
216 return parsed.flatMap(item => toRow(item) ?? [])
217}
218
219const listSessions = async ($: EngineInterface) => {
220 let text = ''
221 for (const limit of LIST_LIMITS) {
222 try {
223 text = await callApp($, 'ccd_session_mgmt', 'list_sessions', { limit })
224 } catch (error) {
225 text = String(error)
226 if (!TOO_LARGE.test(text)) {
227 throw error
228 }
229 continue
230 }
231 if (!TOO_LARGE.test(text)) {
232 return toRows(text)
233 }
234 }
235
236 throw new Error(text.slice(0, 200))
237}
238
239/** This session, which list_sessions leaves out; null when the app does not say. */
240const readThisSession = async ($: EngineInterface) => {
241 try {
242 const text = await callApp($, 'ccd_session_mgmt', 'get_session', { session_id: 'self' })
243 return toRow(parseJson(text)) ?? null
244 } catch {
245 return null
246 }
247}
248
249// The sessions' states, as the desktop's sidebar dots show them. Each running
250// session writes a file of its own under the Claude configuration folder:
251// busy, idle, or waiting on the person. The desktop keeps a record of every
252// session with when the person last looked at it and when it last did
253// something: one that did something since is unread, running or not.
254
255/** Focus written a moment before a turn's end still counts as seen. */
256const UNREAD_SLACK_MS = 2_000
257
258type Live = { host: string; status: string; at: number; updatedAt: number }
259type Seen = { focusedAt: number; activityAt: number }
260type Cached = { stamp: string; value: unknown }
261
262const join = (...parts: string[]) => parts.join(isWindowsPath(parts[0] ?? '') ? '\\' : '/')
263
264const numberOf = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
265
266const fieldsOf = (text: string) => {
267 const value = parseJson(text)
268 return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined
269}
270
271const toLive = (text: string): Live | undefined => {
272 const file = fieldsOf(text)
273 return typeof file?.hostSessionId === 'string' && typeof file.status === 'string'
274 ? {
275 host: file.hostSessionId,
276 status: file.status,
277 at: numberOf(file.statusUpdatedAt),
278 updatedAt: numberOf(file.updatedAt),
279 }
280 : undefined
281}
282
283const toSeen = (text: string): Seen | undefined => {
284 const file = fieldsOf(text)
285 return file === undefined
286 ? undefined
287 : { focusedAt: numberOf(file.lastFocusedAt), activityAt: numberOf(file.lastActivityAt) }
288}
289
290const homeOf = async ($: EngineInterface) =>
291 (await $.env.get('OS')) === 'Windows_NT' ? $.env.get('USERPROFILE') : $.env.get('HOME')
292
293/** The folder each running session writes its own file into. */
294const liveDirOf = async ($: EngineInterface) => {
295 const config = await $.env.get('CLAUDE_CONFIG_DIR')
296 const home = await homeOf($)
297
298 return config ? join(config, 'sessions') : home ? join(home, '.claude', 'sessions') : null
299}
300
301const isDir = async ($: EngineInterface, path: string) => $.fs.exists(path).catch(() => false)
302
303/**
304 * The folder of the desktop's session records: in its own folder of the
305 * user's application data, or, for the Microsoft Store build, in its package.
306 */
307const recordsDirOf = async ($: EngineInterface) => {
308 const tail = ['Claude', 'claude-code-sessions']
309 if ((await $.env.get('OS')) === 'Windows_NT') {
310 const appData = await $.env.get('APPDATA')
311 if (appData && (await isDir($, join(appData, ...tail)))) {
312 return join(appData, ...tail)
313 }
314 const localAppData = await $.env.get('LOCALAPPDATA')
315 const packages = localAppData ? join(localAppData, 'Packages') : null
316 for (const entry of packages === null ? [] : await $.fs.list(packages).catch(() => [])) {
317 const dir = join(packages ?? '', entry.name, 'LocalCache', 'Roaming', ...tail)
318 if (entry.kind === 'dir' && entry.name.startsWith('Claude_') && (await isDir($, dir))) {
319 return dir
320 }
321 }
322 return null
323 }
324 const home = await homeOf($)
325 const config = (await $.env.get('XDG_CONFIG_HOME')) || (home ? join(home, '.config') : '')
326 const candidates = [home ? join(home, 'Library', 'Application Support', ...tail) : '', config ? join(config, ...tail) : '']
327 for (const dir of candidates) {
328 if (dir !== '' && (await isDir($, dir))) {
329 return dir
330 }
331 }
332
333 return null
334}
335
336// Where the states are read from, looked for again at every refresh so that a
337// folder made later is found; and the files read before, by path, each read
338// again only once its size or time moved.
339let sources: { records: string | null; live: string | null } | undefined
340let readCache = new Map<string, Cached>()
341
342const readParsed = async <T,>(
343 $: EngineInterface,
344 path: string,
345 stamp: string,
346 parse: (text: string) => T | undefined,
347 kept: Map<string, Cached>,
348) => {
349 const held = readCache.get(path)
350 const value =
351 held?.stamp === stamp
352 ? (held.value as T | undefined)
353 : parse(await $.fs.read(path).then(String, () => ''))
354 kept.set(path, { stamp, value })
355
356 return value
357}
358
359/** The running sessions' own files, the latest by session where one left another behind. */
360const readLive = async ($: EngineInterface, dir: string, kept: Map<string, Cached>) => {
361 const byHost = new Map<string, Live>()
362 for (const entry of await $.fs.list(dir).catch(() => [])) {
363 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) {
364 continue
365 }
366 const file = await readParsed($, join(dir, entry.name), `${entry.size}:${entry.mtimeMs}`, toLive, kept)
367 const held = file === undefined ? undefined : byHost.get(file.host)
368 if (file !== undefined && (held === undefined || file.updatedAt >= held.updatedAt)) {
369 byHost.set(file.host, file)
370 }
371 }
372
373 return byHost
374}
375
376/** The desktop's records of the sessions wanted, by session id: each file is named for its session. */
377const readSeen = async (
378 $: EngineInterface,
379 dir: string,
380 wanted: ReadonlySet<string>,
381 kept: Map<string, Cached>,
382 depth = 0,
383): Promise<Map<string, Seen>> => {
384 const seen = new Map<string, Seen>()
385 for (const entry of await $.fs.list(dir).catch(() => [])) {
386 const path = join(dir, entry.name)
387 const id = entry.name.replace(/\.json$/, '')
388 if (entry.kind === 'dir' && depth < 3) {
389 for (const [one, record] of await readSeen($, path, wanted, kept, depth + 1)) {
390 seen.set(one, record)
391 }
392 } else if (entry.kind === 'file' && id !== entry.name && wanted.has(id)) {
393 const record = await readParsed($, path, `${entry.size}:${entry.mtimeMs}`, toSeen, kept)
394 if (record !== undefined) {
395 seen.set(id, record)
396 }
397 }
398 }
399
400 return seen
401}
402
403/** A session's state from its own file and its record; this session is never unread, it is in view. */
404const stateOf = (row: SessionRow, own: Live | undefined, seen: Seen | undefined, isMine: boolean) => {
405 if (own?.status === 'waiting') {
406 return 'waiting'
407 }
408 if (own?.status === 'busy' || (own === undefined && row.isRunning)) {
409 return 'running'
410 }
411 const doneAt = Math.max(seen?.activityAt ?? 0, own?.at ?? 0)
412
413 return seen !== undefined && !isMine && doneAt > seen.focusedAt + UNREAD_SLACK_MS ? 'unread' : undefined
414}
415
416/** Reads the listed sessions' states again; the pane and the tabs redraw where one changed. */
417const pollStates = async ($: EngineInterface) => {
418 try {
419 sources ??= { records: await recordsDirOf($), live: await liveDirOf($) }
420 const mine = await read($, thisSession)
421 const others = await read($, sessions)
422 const rows = mine === null ? others : [mine, ...others.filter(row => row.id !== mine.id)]
423 const kept = new Map<string, Cached>()
424 const live = sources.live === null ? new Map<string, Live>() : await readLive($, sources.live, kept)
425 const wanted = new Set(rows.map(row => row.id))
426 const seen = sources.records === null ? new Map<string, Seen>() : await readSeen($, sources.records, wanted, kept)
427 readCache = kept
428
429 const value: Record<string, SessionState> = {}
430 for (const row of rows) {
431 const state = stateOf(row, live.get(row.id), seen.get(row.id), row.id === mine?.id)
432 if (state !== undefined) {
433 value[row.id] = state
434 }
435 }
436 await setStates($, value)
437 } catch {
438 // The states are a hint: the next read tries again.
439 }
440}
441
442/** What status says of the states: where they are read from. */
443const statesLine = () => {
444 if (sources === undefined) {
445 return 'Session states: not read yet.'
446 }
447 if (sources.records === null && sources.live === null) {
448 return 'Session states: unavailable (no session records or session files found).'
449 }
450
451 return `Session states: read from ${sources.records ?? '(no session records)'} and ${sources.live ?? '(no session files)'}.`
452}
453
454const refresh = async ($: EngineInterface) => {
455 await setPins($, await loadPins($))
456 await setFlipped($, toKeys(await $.store.get(FLIPPED_KEY)))
457 await setHere($, await $.session.root())
458 await setThisSession($, await readThisSession($))
459 const at = await $.clock.now()
460
461 try {
462 await setSessions($, await listSessions($))
463 await update($, probe, () => ({ ok: true, error: null, at }))
464 } catch (error) {
465 await update($, probe, () => ({ ok: false, error: String(error), at }))
466 }
467 sources = { records: await recordsDirOf($), live: await liveDirOf($) }
468 await pollStates($)
469}
470
471// Opening a session: its claude:// link, handed to the operating system.
472
473const openLink = async ($: EngineInterface, url: string) => {
474 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
475 const commands = isWindows
476 ? [['rundll32', 'url.dll,FileProtocolHandler', url]]
477 : [
478 ['open', url],
479 ['xdg-open', url],
480 ]
481
482 for (const argv of commands) {
483 try {
484 const ran = await $.process.run(argv, { timeoutMs: 10_000 })
485 if (ran.exitCode === 0) {
486 return true
487 }
488 } catch {
489 // Not on this system: try the next opener.
490 }
491 }
492
493 return false
494}
495
496/**
497 * The app's documented link for a new Code session in a folder. The app asks
498 * the person to confirm the folder each time: a link's folder is not trusted.
499 */
500const newSessionLink = (folder: string) =>
501 `claude://code/new?folder=${encodeURIComponent(folder)}`
502
503const startSession = async ($: EngineInterface, folder: string, label: string) => {
504 if (!(await openLink($, newSessionLink(folder)))) {
505 $.ui.toast(`Could not start a session in ${label}.`)
506 }
507}
508
509const openSession = async ($: EngineInterface, row: SessionRow) => {
510 if (row.id === (await read($, thisSession))?.id) {
511 $.ui.toast('You are in this session.')
512 return
513 }
514 if (row.link === null || !(await openLink($, row.link))) {
515 $.ui.toast(`Could not open "${row.title}".`)
516 }
517}
518
519const goTo = async ($: EngineInterface, target: Pin) => {
520 await refresh($)
521 const list = await read($, pins)
522 if (pinOf(await read($, here), list)?.folder === target.folder) {
523 $.ui.toast(`You are in ${target.label} already.`)
524 return
525 }
526
527 const latest = (await read($, sessions))
528 .filter(row => row.link !== null && pinOf(row.folder, list)?.folder === target.folder)
529 .sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt))[0]
530 if (latest === undefined) {
531 await startSession($, target.folder, target.label)
532 return
533 }
534 await openSession($, latest)
535}
536
537// The Projects pane: the pinned projects in the order of their tabs, then the
538// other projects with a session among the recent ones, the latest first, and
539// This computer last.
540
541type Project = {
542 key: string
543 /** Its folder; null for This computer, whose sessions each have their own. */
544 folder: string | null
545 label: string
546 isPinned: boolean
547 /** Its sessions, the latest first. */
548 rows: SessionRow[]
549}
550
551const projectsOf = (list: readonly Pin[], rows: readonly SessionRow[]) => {
552 const latestFirst = [...rows].sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt))
553 const pinned: Project[] = list.map(pin => ({
554 key: keyOf(pin.folder),
555 folder: pin.folder,
556 label: pin.label,
557 isPinned: true,
558 rows: latestFirst.filter(row => pinOf(row.folder, list)?.folder === pin.folder),
559 }))
560
561 const rest = new Map<string, Project>()
562 for (const row of latestFirst) {
563 if (pinOf(row.folder, list) !== undefined) {
564 continue
565 }
566 const key = keyOf(row.folder)
567 const folder = key === SCRATCH_KEY ? null : projectFolder(row.folder)
568 const project: Project = rest.get(key) ?? {
569 key,
570 folder,
571 label: folder === null ? SCRATCH_LABEL : baseName(folder),
572 isPinned: false,
573 rows: [],
574 }
575 project.rows.push(row)
576 rest.set(key, project)
577 }
578 const others = [...rest.values()]
579
580 return {
581 pinned,
582 rest: [
583 ...others.filter(project => project.key !== SCRATCH_KEY),
584 ...others.filter(project => project.key === SCRATCH_KEY),
585 ],
586 }
587}
588
589/** How long ago an ISO time was, in the unit that reads best: 5m, 3h, 2d, 6w. */
590const ago = (iso: string, now: number) => {
591 const then = Date.parse(iso)
592 if (Number.isNaN(then) || now === 0) {
593 return ''
594 }
595
596 const minutes = Math.max(0, Math.floor((now - then) / 60_000))
597 if (minutes < 1) {
598 return 'now'
599 }
600 if (minutes < 60) {
601 return `${minutes}m`
602 }
603 const hours = Math.floor(minutes / 60)
604 if (hours < 24) {
605 return `${hours}h`
606 }
607 const days = Math.floor(hours / 24)
608
609 return days < 7 ? `${days}d` : `${Math.floor(days / 7)}w`
610}
611
612/** Cuts a text to a number of characters, an ellipsis last where it was cut. */
613const clip = (text: string, room: number) => {
614 const chars = Array.from(text)
615
616 return chars.length <= room ? text : `${chars.slice(0, Math.max(1, room - 1)).join('')}…`
617}
618
619// Buttons kept across redraws. A Button built again gets a new handle and the
620// engine lets the old one go, so a click the surface sends from the drawing it
621// still shows runs nothing, and the surface only draws again. Redraws land
622// between a drawing and a click often enough to lose first clicks: the pane
623// taking the keyboard, the desktop asking for every drawing again, the session
624// list read again. A Button whose look and action have not changed is
625// therefore handed back as it was, its handle with it.
626
627type Kept = { inputs: string; element: unknown }
628
629/** Each site's Buttons by key, as its latest drawing handed them out. */
630const keptBySite = new Map<string, Map<string, Kept>>()
631
632/**
633 * What one drawing builds its Buttons with. Taken once the drawing's reads are
634 * done, so drawings of one site do not interleave; the Buttons a drawing leaves
635 * out are let go.
636 */
637const keeperOf = (e: { surface: string; component: string; requestId: string }) => {
638 const site = [e.surface, e.component, e.requestId].join('\u0000')
639 const before = keptBySite.get(site) ?? new Map<string, Kept>()
640 const after = new Map<string, Kept>()
641 keptBySite.set(site, after)
642
643 /** The Button under `key`, built again only when `inputs`, what it shows and does, change. */
644 return <T,>(key: string, inputs: unknown[], build: () => T): T => {
645 const said = JSON.stringify(inputs)
646 const held = before.get(key)
647 const element = held?.inputs === said ? (held.element as T) : build()
648 after.set(key, { inputs: said, element })
649
650 return element
651 }
652}
653
654// The look: each project's icon, the fill that marks the current row, and the
655// wash that lights a row under the pointer.
656
657/** Each pair is a light square and its dark letter; a dark theme swaps the two. */
658const SWATCHES = [
659 ['#F5C4B3', '#712B13'],
660 ['#9FE1CB', '#085041'],
661 ['#CECBF6', '#3C3489'],
662 ['#C0DD97', '#27500A'],
663 ['#B5D4F4', '#0C447C'],
664 ['#FAC775', '#633806'],
665 ['#F4C0D1', '#72243E'],
666] as const
667
668const ICON_SIZE = 20
669
670/** A project's colors, picked from its key so that a renamed tab keeps them. */
671const swatchOf = (key: string) => {
672 let hash = 0
673 for (const char of key) {
674 hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0
675 }
676
677 return SWATCHES[hash % SWATCHES.length] ?? SWATCHES[0]
678}
679
680/** The first letter or digit of a name: what its icon shows. */
681const letterOf = (name: string) => (/[\p{L}\p{N}]/u.exec(name)?.[0] ?? '?').toUpperCase()
682
683const escapeXml = (text: string) => text.replace(/[<>&"']/g, char => `&#${char.charCodeAt(0)};`)
684
685const SVG_OPEN = `<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 20 20">`
686
687/** A project's letter on its square, in the theme's light or dark. */
688const letterSvg = (key: string, label: string) => {
689 const [fill, ink] = swatchOf(key)
690
691 return (
692 SVG_OPEN +
693 `<style>.s{fill:${fill}}.l{fill:${ink}}` +
694 `@media (prefers-color-scheme: dark){.s{fill:${ink}}.l{fill:${fill}}}</style>` +
695 '<rect class="s" x="2" y="2" width="16" height="16" rx="4"/>' +
696 '<text class="l" x="10" y="10.5" font-size="10" font-weight="600" ' +
697 'font-family="system-ui, -apple-system, Segoe UI, sans-serif" text-anchor="middle" ' +
698 `dominant-baseline="central">${escapeXml(letterOf(label))}</text></svg>`
699 )
700}
701
702/** This computer's icon: a screen on its stand. */
703const COMPUTER_SVG =
704 SVG_OPEN +
705 '<style>.m{stroke:#73726c}@media (prefers-color-scheme: dark){.m{stroke:#a3a29c}}</style>' +
706 '<g class="m" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' +
707 '<rect x="3" y="4" width="14" height="9.5" rx="1.5"/><path d="M7.5 16.5h5M10 13.5v3"/></g></svg>'
708
709/**
710 * A project's icon: an SVG where the surface draws one, else its letter in a
711 * colored cell. The terminal's table answers Svg too, with an empty stand-in.
712 */
713const iconOf = (els: ElementTable, surface: string, key: string, label: string) => {
714 const { Text } = els
715 if (surface !== 'terminal' && 'Svg' in els) {
716 const { Svg } = els
717 const source = key === SCRATCH_KEY ? COMPUTER_SVG : letterSvg(key, label)
718
719 return <Svg source={source} alt={label} width={ICON_SIZE} height={ICON_SIZE} />
720 }
721 if (key === SCRATCH_KEY) {
722 return <Text dimColor>{' ▪ '}</Text>
723 }
724 const [fill, ink] = swatchOf(key)
725
726 return (
727 <Text backgroundColor={fill} color={ink}>
728 {` ${letterOf(label)} `}
729 </Text>
730 )
731}
732
733// The desktop's own fill for what is selected, and a wash for what the pointer is over.
734const ACTIVE = 'userMessageBackground'
735const LIT = 'rgba(128, 128, 128, 0.14)'
736
737/** Whether a surface has a pointer to hover with: the terminal and the phone have none. */
738const canHover = (surface: string) => surface === 'desktop' || surface === 'vscode'
739
740/** A row's fill: the selected row filled, any other lit while the pointer is over it. */
741const fillOf = (surface: string, isSelected: boolean) =>
742 isSelected
743 ? { backgroundColor: ACTIVE }
744 : canHover(surface)
745 ? { backgroundColor: 'transparent', hover: { backgroundColor: LIT } }
746 : {}
747
748/** A Button in a lit row: its own highlight under the pointer off, the row's is enough. */
749const quietOf = (surface: string) =>
750 canHover(surface) ? { hover: { backgroundColor: 'transparent' } } : {}
751
752// A session's state as the sidebar's dot draws it, and the count of a
753// project's sessions that need the person, in the color of the most urgent.
754
755type Shown = SessionState | 'idle'
756
757/** Each state's dot: [light theme, dark theme]. */
758const DOT_COLORS: Record<Shown, readonly [string, string]> = {
759 waiting: ['#BA7517', '#EF9F27'],
760 unread: ['#378ADD', '#85B7EB'],
761 running: ['#888780', '#B4B2A9'],
762 idle: ['#B4B2A9', '#5F5E5A'],
763}
764
765/** A count's pill: [fill, number] in a light theme, then in a dark one. */
766const BADGE_COLORS = {
767 waiting: ['#FAEEDA', '#633806', '#633806', '#FAC775'],
768 unread: ['#E6F1FB', '#0C447C', '#0C447C', '#B5D4F4'],
769} as const
770
771const STATE_LABELS: Record<Shown, string> = {
772 waiting: 'Waiting for you',
773 unread: 'Unread',
774 running: 'Running',
775 idle: 'Idle',
776}
777
778/** The terminal's theme colors for the states. */
779const STATE_THEME: Record<SessionState, string> = {
780 waiting: 'warning',
781 unread: 'suggestion',
782 running: 'inactive',
783}
784
785const DOT_WIDTH = 12
786
787/** A state's dot: filled, pulsing while the session runs, a ring when it is idle. */
788const dotSvg = (state: Shown) => {
789 const [light, dark] = DOT_COLORS[state]
790 const paint = state === 'idle' ? 'fill:none;stroke:' : 'fill:'
791 const pulse =
792 state === 'running'
793 ? '<animate attributeName="opacity" values="1;0.35;1" dur="1.6s" repeatCount="indefinite"/>'
794 : ''
795
796 return (
797 `<svg xmlns="http://www.w3.org/2000/svg" width="${DOT_WIDTH}" height="${ICON_SIZE}" viewBox="0 0 ${DOT_WIDTH} ${ICON_SIZE}">` +
798 `<style>.d{${paint}${light}}@media (prefers-color-scheme: dark){.d{${paint}${dark}}}</style>` +
799 `<circle class="d" cx="6" cy="10" r="${state === 'idle' ? 3.25 : 4}" stroke-width="1.5">${pulse}</circle></svg>`
800 )
801}
802
803/** A count as a pill, and how wide it is drawn. */
804const badgeSvg = (count: number, state: keyof typeof BADGE_COLORS) => {
805 const text = count > 99 ? '99+' : String(count)
806 const width = Math.max(18, 10 + text.length * 6)
807 const [fill, ink, darkFill, darkInk] = BADGE_COLORS[state]
808 const source =
809 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${ICON_SIZE}" viewBox="0 0 ${width} ${ICON_SIZE}">` +
810 `<style>.p{fill:${fill}}.n{fill:${ink}}` +
811 `@media (prefers-color-scheme: dark){.p{fill:${darkFill}}.n{fill:${darkInk}}}</style>` +
812 `<rect class="p" x="0" y="2" width="${width}" height="16" rx="8"/>` +
813 `<text class="n" x="${width / 2}" y="10.5" font-size="10.5" font-weight="600" ` +
814 'font-family="system-ui, -apple-system, Segoe UI, sans-serif" text-anchor="middle" ' +
815 `dominant-baseline="central">${text}</text></svg>`
816
817 return { source, width }
818}
819
820/** A session's dot: an SVG where the surface draws one, else a glyph in the theme's color. */
821const dotOf = (els: ElementTable, surface: string, state: SessionState | undefined) => {
822 const { Text } = els
823 if (surface !== 'terminal' && 'Svg' in els) {
824 const { Svg } = els
825 const shown = state ?? 'idle'
826
827 return <Svg source={dotSvg(shown)} alt={STATE_LABELS[shown]} width={DOT_WIDTH} height={ICON_SIZE} />
828 }
829
830 return state === undefined ? <Text>{' '}</Text> : <Text color={STATE_THEME[state]}>●</Text>
831}
832
833/**
834 * What a project's row and tab show of its sessions: how many wait for the
835 * person or are unread, else a running dot while one runs, else nothing.
836 */
837const markOf = (
838 els: ElementTable,
839 surface: string,
840 ids: readonly string[],
841 value: Readonly<Record<string, SessionState>>,
842) => {
843 const list = ids.map(id => value[id])
844 const waiting = list.filter(state => state === 'waiting').length
845 const need = waiting + list.filter(state => state === 'unread').length
846 if (need === 0) {
847 return list.includes('running') ? dotOf(els, surface, 'running') : null
848 }
849 const state = waiting > 0 ? 'waiting' : 'unread'
850 const { Text } = els
851 if (surface !== 'terminal' && 'Svg' in els) {
852 const { Svg } = els
853 const { source, width } = badgeSvg(need, state)
854
855 return <Svg source={source} alt={`${need} need you`} width={width} height={ICON_SIZE} />
856 }
857
858 return (
859 <Text color={STATE_THEME[state]} bold>
860 {String(need)}
861 </Text>
862 )
863}
864
865/** Opens the pane at the person's ask: what to tell them when it is not drawn, or nothing. */
866const showPane = async ($: EngineInterface) => {
867 await $.store.set(CLOSED_KEY, false)
868 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
869
870 return opened.isPlaced ? '' : `The Projects pane is not drawn yet: ${opened.reason}`
871}
872
873// Changing the pins: the command, the model's tool, the band and the pane share these.
874
875const indexOf = (list: readonly Pin[], key: string) => {
876 const wanted = key.trim()
877 if (/^\d+$/.test(wanted)) {
878 const index = Number(wanted) - 1
879 return index >= 0 && index < list.length ? index : -1
880 }
881
882 return list.findIndex(
883 pin =>
884 pin.label.toLowerCase() === wanted.toLowerCase() ||
885 normalize(pin.folder) === normalize(wanted),
886 )
887}
888
889const pinFolder = async ($: EngineInterface, folder: string, label?: string) => {
890 if (isScratch(folder)) {
891 return `${folder} is a scratch folder the app made for a session started without one: there is no project to pin.`
892 }
893 const list = await loadPins($)
894 const existing = list.find(pin => keyOf(pin.folder) === keyOf(folder))
895 if (existing !== undefined) {
896 return `${existing.label} is pinned already.`
897 }
898
899 const added = { folder, label: label?.trim() || baseName(folder) }
900 await savePins($, [...list, added])
901 await setFlip($, keyOf(folder), false)
902 await refresh($)
903
904 return `Pinned ${added.label} (${folder}): it has a tab, and stays at the top of the Projects pane.`
905}
906
907const unpinTab = async ($: EngineInterface, key: string) => {
908 const list = await loadPins($)
909 const removed = list[indexOf(list, key)]
910 if (removed === undefined) {
911 return `No tab matches "${key}".`
912 }
913
914 await savePins($, list.filter(pin => pin !== removed))
915 await setFlip($, keyOf(removed.folder), false)
916
917 return `Unpinned ${removed.label}.`
918}
919
920const moveTab = async ($: EngineInterface, key: string, to: number) => {
921 const list = await loadPins($)
922 const moved = list[indexOf(list, key)]
923 if (moved === undefined) {
924 return `No tab matches "${key}".`
925 }
926
927 const others = list.filter(pin => pin !== moved)
928 const target = Math.min(Math.max(to, 1), others.length + 1) - 1
929 others.splice(target, 0, moved)
930 await savePins($, others)
931
932 return `Moved ${moved.label} to position ${target + 1}.`
933}
934
935const renameTab = async ($: EngineInterface, key: string, label: string) => {
936 const list = await loadPins($)
937 const old = list[indexOf(list, key)]
938 if (old === undefined) {
939 return `No tab matches "${key}".`
940 }
941
942 const name = label.trim()
943 await savePins($, list.map(pin => (pin === old ? { ...old, label: name } : pin)))
944
945 return `Renamed ${old.label} to ${name}.`
946}
947
948const describe = (list: readonly Pin[], rows: readonly SessionRow[]) => {
949 if (list.length === 0) {
950 return 'No pinned folders yet. Run /env-tabs pin in a session to pin its project.'
951 }
952
953 return list
954 .map((pin, index) => {
955 const count = rows.filter(row => pinOf(row.folder, list)?.folder === pin.folder).length
956 return `${index + 1}. ${pin.label} (${pin.folder}; ${count} other session${count === 1 ? '' : 's'})`
957 })
958 .join('\n')
959}
960
961const status = async ($: EngineInterface) => {
962 await refresh($)
963 const last = await read($, probe)
964 const rows = await read($, sessions)
965
966 return [
967 `This session's folder: ${await read($, here)}`,
968 last?.ok
969 ? `Session list: ${rows.length} other sessions read.`
970 : `Session list: unavailable (${last?.error ?? 'not read yet'}).`,
971 statesLine(),
972 '',
973 describe(await read($, pins), rows),
974 ].join('\n')
975}
976
977const runCommand = async ($: EngineInterface, args: string) => {
978 const trimmed = args.trim()
979 const [verb = 'list'] = trimmed.split(/\s+/, 1).filter(Boolean)
980 const tail = trimmed.slice(verb.length).trim()
981
982 switch (verb.toLowerCase()) {
983 case 'list': {
984 await refresh($)
985 return describe(await read($, pins), await read($, sessions))
986 }
987 case 'pin':
988 return pinFolder($, tail || projectFolder(await $.session.root()))
989 case 'unpin':
990 return tail ? unpinTab($, tail) : HELP
991 case 'move': {
992 const match = /^(.*\S)\s+(\d+)$/.exec(tail)
993 return match?.[1] && match[2] ? moveTab($, match[1], Number(match[2])) : HELP
994 }
995 case 'rename': {
996 const match = /^(\d+)\s+(.*\S)$/.exec(tail)
997 return match?.[1] && match[2] ? renameTab($, match[1], match[2]) : HELP
998 }
999 case 'panel':
1000 return (await showPane($)) || 'The Projects pane is open.'
1001 case 'status':
1002 return status($)
1003 default:
1004 return HELP
1005 }
1006}
1007
1008type ToolInput = {
1009 action?: string
1010 folder?: string
1011 tab?: string
1012 label?: string
1013 position?: number
1014}
1015
1016const runTool = async ($: EngineInterface, input: ToolInput) => {
1017 switch (input.action) {
1018 case 'pin':
1019 return pinFolder(
1020 $,
1021 input.folder?.trim() || projectFolder(await $.session.root()),
1022 input.label,
1023 )
1024 case 'unpin':
1025 return input.tab ? unpinTab($, input.tab) : 'Name the tab to unpin.'
1026 case 'move':
1027 return input.tab && input.position
1028 ? moveTab($, input.tab, input.position)
1029 : 'Name the tab and its new position.'
1030 case 'rename':
1031 return input.tab && input.label?.trim()
1032 ? renameTab($, input.tab, input.label)
1033 : 'Name the tab and its new label.'
1034 case 'panel':
1035 return (await showPane($)) || 'The Projects pane is open.'
1036 default:
1037 return status($)
1038 }
1039}
1040
1041export const register: Register = on => {
1042 on('session.start', async ($, e, next) => {
1043 await $.command.register({
1044 name: 'env-tabs',
1045 description: 'Pinned projects as tabs above the prompt and at the top of a Projects pane',
1046 argumentHint: '[list|pin|unpin|move|rename|panel|status]',
1047 })
1048 await $.tool.register({
1049 name: 'tabs',
1050 description:
1051 "Reads and changes env-tabs: pinned projects (folders), each drawn as a tab above the prompt in Claude Desktop's Code tab " +
1052 'and kept at the top of its Projects pane, which lists every recent project with its sessions. ' +
1053 'action "status" lists the pins and what they see; "pin" pins a folder (this session\'s project when none is given); ' +
1054 '"unpin", "move" and "rename" take a tab by its 1-based number or its label; "panel" opens the Projects pane.',
1055 inputSchema: {
1056 type: 'object',
1057 properties: {
1058 action: {
1059 type: 'string',
1060 enum: ['status', 'pin', 'unpin', 'move', 'rename', 'panel'],
1061 },
1062 folder: { type: 'string', description: 'The absolute folder to pin (pin only).' },
1063 tab: { type: 'string', description: 'The tab: its 1-based number or its label.' },
1064 label: { type: 'string', description: 'The text on the tab (pin, rename).' },
1065 position: {
1066 type: 'integer',
1067 minimum: 1,
1068 description: 'The new 1-based position (move).',
1069 },
1070 },
1071 required: ['action'],
1072 },
1073 isDeferred: true,
1074 })
1075 const started = await next(e)
1076 sources = undefined
1077 readCache = new Map()
1078 void refresh($)
1079 $.clock.every(REFRESH_MS, () => refresh($))
1080 $.clock.every(STATES_MS, () => pollStates($))
1081 // The pane opens by itself where the surface has room for it beside the
1082 // transcript, unless the person closed it.
1083 if ((await $.store.get(CLOSED_KEY)) !== true) {
1084 void $.ui.open({ id: PANE, title: PANE_TITLE })
1085 }
1086
1087 return started
1088 })
1089
1090 // A pane the person closed stays closed in later sessions, until they open it again.
1091 on('ui.close', { id: PANE }, async ($, e, next) => {
1092 if (e.origin.kind === 'person') {
1093 await $.store.set(CLOSED_KEY, true)
1094 }
1095
1096 return next(e)
1097 })
1098
1099 on('command.run', { command: 'env-tabs' }, async ($, e) => ({
1100 text: await runCommand($, e.args),
1101 }))
1102
1103 on('tool.call', { tool: TOOL }, async ($, e) => ({
1104 result: await runTool($, e as unknown as ToolInput),
1105 })).catch(() => ({ result: 'env-tabs could not answer; run /env-tabs status to see why.' }))
1106
1107 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1108 const folder = await read($, here)
1109 if (e.props.hasSurvey || folder === '') {
1110 return next(e)
1111 }
1112
1113 const list = await read($, pins)
1114 const mine = await read($, thisSession)
1115 const others = await read($, sessions)
1116 const value = await read($, states)
1117 const rows = mine === null ? others : [mine, ...others.filter(row => row.id !== mine.id)]
1118 const current = pinOf(folder, list)
1119 const els = $.ui.resolve(e)
1120 const { Box, Button } = els
1121 const keep = keeperOf(e)
1122 const quiet = quietOf(e.surface)
1123
1124 return (
1125 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1}>
1126 {list.map(pin => {
1127 const key = `tab:${normalize(pin.folder)}`
1128 const isCurrent = current?.folder === pin.folder
1129 const ids = rows.filter(row => pinOf(row.folder, list)?.folder === pin.folder).map(row => row.id)
1130
1131 return (
1132 <Box
1133 key={`tabrow:${normalize(pin.folder)}`}
1134 flexDirection="row"
1135 alignItems="center"
1136 columnGap={1}
1137 paddingRight={1}
1138 {...fillOf(e.surface, isCurrent)}
1139 >
1140 {iconOf(els, e.surface, keyOf(pin.folder), pin.label)}
1141 {keep(key, [pin, isCurrent], () => (
1142 <Button
1143 key={key}
1144 plain
1145 dimColor={!isCurrent}
1146 {...quiet}
1147 label={pin.label}
1148 onPress={() => goTo($, pin)}
1149 />
1150 ))}
1151 {markOf(els, e.surface, ids, value)}
1152 </Box>
1153 )
1154 })}
1155 {current === undefined &&
1156 !isScratch(folder) &&
1157 keep('pin-here', [folder], () => (
1158 <Button
1159 key="pin-here"
1160 plain
1161 label="+ Pin this folder"
1162 onPress={async () => $.ui.toast(await pinFolder($, projectFolder(folder)))}
1163 />
1164 ))}
1165 {keep('projects', [], () => (
1166 <Button
1167 key="projects"
1168 plain
1169 dimColor
1170 label="Projects"
1171 onPress={async () => {
1172 const said = await showPane($)
1173 if (said !== '') {
1174 $.ui.toast(said)
1175 }
1176 }}
1177 />
1178 ))}
1179 </Box>
1180 )
1181 })
1182
1183 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1184 const els = $.ui.resolve(e)
1185 const { Box, Button, Text } = els
1186 const list = await read($, pins)
1187 const mine = await read($, thisSession)
1188 const others = await read($, sessions)
1189 const folder = await read($, here)
1190 const flips = await read($, flipped)
1191 const value = await read($, states)
1192 const last = await read($, probe)
1193 const now = last?.at ?? 0
1194 const room = Math.max(8, e.props.bodyColumns - 10)
1195 // A project's row also holds its + New and Pin or Unpin buttons.
1196 const labelRoom = Math.max(8, e.props.bodyColumns - 18)
1197 const rows = mine === null ? others : [mine, ...others.filter(row => row.id !== mine.id)]
1198 const { pinned, rest } = projectsOf(list, rows)
1199 const current = folder === '' ? '' : keyOf(folder)
1200 const keep = keeperOf(e)types/index.d.ts 53 lines1/** A pinned folder: one tab above the prompt, and a place at the top of the Projects pane. */
2export type Pin = {
3 /** The folder, absolute, as the session that pinned it reported it. */
4 folder: string
5 /** The text on the tab, and the project's name in the pane. */
6 label: string
7}
8
9/** One Code session as the desktop app lists it. */
10export type SessionRow = {
11 id: string
12 title: string
13 /** The session's working folder. */
14 folder: string
15 /** A claude:// link that opens the session in the app; null where the app gives none. */
16 link: string | null
17 isRunning: boolean
18 /** ISO time of the session's last activity. */
19 lastActivityAt: string
20}
21
22/**
23 * What a session wants, as the desktop's sidebar dot says: waiting on the
24 * person (a permission or a question), finished since they last looked at it,
25 * or running. An idle session has none.
26 */
27export type SessionState = 'waiting' | 'unread' | 'running'
28
29/** How the last read of the session list went. */
30export type Probe = {
31 ok: boolean
32 error: string | null
33 at: number
34}
35
36declare module 'claude-code' {
37 interface PluginState {
38 'env-tabs': {
39 pins: Pin[]
40 /** The other sessions, as list_sessions answers: it leaves this one out. */
41 sessions: SessionRow[]
42 /** This session, or null while the app has not said. */
43 thisSession: SessionRow | null
44 here: string
45 /** Projects the person opened or closed in the pane, by their normalized folder. */
46 flipped: string[]
47 /** Each listed session's state, by its id; an idle session is left out. */
48 states: Record<string, SessionState>
49 probe: Probe | null
50 }
51 }
52}
53