A right-hand sidebar for the session: every link mentioned, things to know, and action items you tick off into a collapsible Done list.

A Claude Code mod. Part of my wider collection, claude-mods.
A right-hand sidebar for the session with three lists: Action items, Things to know and Links. Tick an action item and it is crossed out, then folds into a collapsed Done dropdown, so it is still there but out of the way. Inspired by samaphp/session-links, which puts links in a band above the prompt; this one moves them to a side panel and adds notes and to-dos.
claude plugin marketplace add icedevil2001/session-sidebar
claude plugin install session-sidebar@session-sidebar --scope user
Needs a Claude Code build with mods (tested on 2.1.295).
/sidebar opens it again after you close it.× dismisses a link.note tool when something should outlive the turn, such as a caveat, or a step only you can do. You can add your own action items in the + box.☐ ticks an item. It stays crossed out for a moment, then moves under ▸ Done (n). Open the dropdown to see it, and press ☑ / ↺ to bring it back.--resume.note tool's schema is in Claude's tool list, which costs a few hundred tokens of context. Each call to it is a normal tool call./clear the sidebar keeps the previous session's items until the next start.ponytail: note in hooks/register.tsx).hooks/register.tsx 295 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Item, Kind } from '../types'
5
6const PANE = 'session-sidebar'
7const TOOL = 'mcp__session-sidebar__note'
8// How long a ticked item stays crossed out in place before it folds into Done.
9const SETTLE_MS = 1500
10const URL_RE = /https?:\/\/[^\s<>"'`\\^{}|]+/gi
11
12const sessionId = atom({ plugin: 'session-sidebar', key: 'sessionId' } as const, '')
13const items = atom({ plugin: 'session-sidebar', key: 'items' } as const, [])
14const showDone = atom({ plugin: 'session-sidebar', key: 'showDone' } as const, false)
15const settling = atom({ plugin: 'session-sidebar', key: 'settling' } as const, [])
16
17type Engine = EngineInterface
18type Row = { agentId?: string; message: { content: unknown } }
19
20const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`)
21const tally = (text: string, mark: string): number => text.split(mark).length - 1
22
23/** Drops sentence punctuation off a URL's end; a `)` or `]` stays when the URL opened it (`/wiki/Foo_(bar)`). */
24function trimTail(url: string): string {
25 for (;;) {
26 const last = url.at(-1) ?? ''
27 const isLoose =
28 last === ')' ? tally(url, ')') > tally(url, '(') : last === ']' ? tally(url, ']') > tally(url, '[') : '.,;:!?*_~'.includes(last)
29
30 if (url === '' || !isLoose) {
31 return url
32 }
33
34 url = url.slice(0, -1)
35 }
36}
37
38/** The http(s) links a text mentions, normalized, each once. Injected reminders are not the conversation. */
39export function extractUrls(text: string): string[] {
40 const found = new Set<string>()
41 const said = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ')
42
43 for (const [raw] of said.matchAll(URL_RE)) {
44 const url = trimTail(raw)
45
46 // `https://host/…` was shortened by its writer and opens nothing.
47 if (url.includes('…') || !URL.canParse(url)) {
48 continue
49 }
50
51 const { hostname, port, href } = new URL(url)
52
53 if (hostname.includes('.') || hostname === 'localhost' || port !== '') {
54 found.add(href)
55 }
56 }
57
58 return [...found]
59}
60
61/** Host and path, the query folded: that is where tokens ride. */
62const displayOf = (url: string, max: number): string => {
63 const { host, pathname, search } = new URL(url)
64
65 return clip(host.replace(/^www\./, '') + pathname.replace(/\/$/, '') + (search === '' ? '' : '?…'), max)
66}
67
68async function save($: Engine): Promise<void> {
69 const id = await read($, sessionId)
70
71 // ponytail: one store key per session, never pruned; prune by age if $.store's 4 MiB fills.
72 if (id !== '') {
73 await $.store.set(`session:${id}`, await read($, items))
74 }
75}
76
77async function add($: Engine, kind: Kind, texts: readonly string[], from: Item['from']): Promise<number> {
78 let added = 0
79
80 await update($, items, list => {
81 const next = [...list]
82
83 for (const raw of texts) {
84 const text = raw.replace(/\p{Cc}/gu, ' ').trim().slice(0, 300)
85 const id = kind === 'link' ? `link:${text}` : `${kind}:${crypto.randomUUID()}`
86
87 // A link mentioned again is the same item, even dismissed: a mention never undoes a decision.
88 if (text !== '' && !next.some(item => item.id === id)) {
89 next.push({ id, kind, text, from })
90 added += 1
91 }
92 }
93
94 return next
95 })
96
97 if (added > 0) {
98 await save($)
99 }
100
101 return added
102}
103
104/** Ticks a todo or dismisses a link/note; the item stays crossed out in place for a moment, then folds into Done. */
105async function setDone($: Engine, id: string, done: boolean): Promise<void> {
106 await update($, items, list => list.map(item => (item.id === id ? { ...item, done } : item)))
107 await save($)
108
109 if (done) {
110 await update($, settling, ids => [...ids, id])
111 $.clock.after(SETTLE_MS, () => void update($, settling, ids => ids.filter(one => one !== id)))
112 }
113}
114
115async function capture($: Engine, row: Row, from: Item['from']): Promise<void> {
116 // A subagent's conversation is its own; what matters of it reaches the main one.
117 if (row.agentId !== undefined || !Array.isArray(row.message.content)) {
118 return
119 }
120
121 const text = row.message.content
122 .map(block => (block?.type === 'text' && typeof block.text === 'string' ? block.text : ''))
123 .join('\n')
124
125 await add($, 'link', extractUrls(text), from)
126}
127
128/** Never let a sidebar failure cost the conversation a row or a turn. */
129async function quietly($: Engine, what: string, work: () => Promise<unknown>): Promise<void> {
130 try {
131 await work()
132 } catch (error) {
133 $.ui.log(`could not ${what}: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
134 }
135}
136
137const openPane = ($: Engine) => $.ui.open({ id: PANE, title: 'Session' })
138
139export const register: Register = on => {
140 on('session.start', async ($, e, next) => {
141 const started = await next(e)
142 const id = await $.session.id()
143 const saved = await $.store.get(`session:${id}`)
144
145 // A hot reload keeps the host's state: only a different session reloads from the store.
146 if ((await read($, sessionId)) !== id) {
147 await update($, items, () => (Array.isArray(saved) ? (saved as Item[]) : []))
148 await update($, sessionId, () => id)
149 }
150
151 await $.command.register({ name: 'sidebar', description: 'Open the session sidebar: links, things to know, action items' })
152 await $.tool.register({
153 name: 'note',
154 isDeferred: false,
155 description:
156 "Pin short items to the person's session sidebar. kind 'todo': an action item the PERSON must do themselves " +
157 "(rotate a key, run a command on their machine, reply to someone); never work you will do. kind 'know': a fact, " +
158 'caveat or decision they should keep in mind after this turn scrolls away. One line each, under 100 characters. ' +
159 'Use sparingly, only for things that outlive the current turn. Links are collected automatically; do not add them.',
160 inputSchema: {
161 type: 'object',
162 properties: {
163 items: {
164 type: 'array',
165 items: {
166 type: 'object',
167 properties: { kind: { type: 'string', enum: ['todo', 'know'] }, text: { type: 'string' } },
168 required: ['kind', 'text'],
169 },
170 },
171 },
172 required: ['items'],
173 },
174 })
175 void openPane($)
176
177 return started
178 })
179
180 on('tool.call', { tool: TOOL }, async ($, e) => {
181 const given = (e as unknown as { items?: unknown }).items
182 const list = Array.isArray(given) ? (given as { kind?: unknown; text?: unknown }[]) : []
183 let added = 0
184
185 for (const kind of ['todo', 'know'] as const) {
186 const texts = list.filter(one => one?.kind === kind && typeof one.text === 'string').map(one => one.text as string)
187 added += await add($, kind, texts, 'claude')
188 }
189
190 return { result: `Added ${added} item${added === 1 ? '' : 's'} to the sidebar.` }
191 })
192
193 // What the person typed and what Claude answered. Tool output stays out: one search result would bury the rest.
194 on('session.append', { door: 'prompt' }, async ($, e, next) => {
195 await quietly($, 'collect links', () => capture($, e, 'you'))
196
197 return next(e)
198 })
199
200 on('session.append', { door: 'response' }, async ($, e, next) => {
201 await quietly($, 'collect links', () => capture($, e, 'claude'))
202
203 return next(e)
204 })
205
206 on('command.run', { command: 'sidebar' }, async $ => {
207 const opened = await openPane($)
208
209 return { text: opened.isPlaced ? 'Session sidebar opened.' : `Session sidebar is waiting for room: ${opened.reason}` }
210 })
211
212 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
213 const kit = $.ui.resolve(e)
214 const { Box, Button, Link, Text } = kit
215 // The mobile app draws no text field: there the add box is left out.
216 const Field = 'Input' in kit ? kit.Input : undefined
217 const all = await read($, items)
218 const fresh = new Set(await read($, settling))
219 const isDoneOpen = await read($, showDone)
220 const width = Math.max(12, e.props.bodyColumns - 5)
221 const run = (what: string, work: () => Promise<unknown>) => () => quietly($, what, work)
222 const shown = (kind: Kind) => all.filter(item => item.kind === kind && (!item.done || fresh.has(item.id)))
223 const done = all.filter(item => item.done && !fresh.has(item.id))
224
225 // The control leads the row so a narrow dock never pushes it out of reach.
226 const row = (item: Item, isInDone: boolean) => {
227 const isStruck = item.done === true
228 const mark = item.kind === 'todo' ? (isStruck ? ' ☑ ' : ' ☐ ') : isStruck ? ' ↺ ' : ' × '
229
230 return (
231 <Box key={`row:${item.id}`} flexDirection="row">
232 <Button
233 key={`mark:${item.id}`}
234 plain
235 dimColor={item.kind !== 'todo' && !isInDone}
236 label={mark}
237 hover={{ color: item.kind === 'todo' ? 'success' : 'error' }}
238 onPress={run('update the item', () => setDone($, item.id, !isStruck))}
239 />
240 {item.kind === 'link' && !isStruck ? (
241 <Link href={item.text} label={displayOf(item.text, width)} />
242 ) : (
243 <Text strikethrough={isStruck} dimColor={isStruck}>
244 {item.kind === 'link' ? displayOf(item.text, width) : item.text}
245 </Text>
246 )}
247 </Box>
248 )
249 }
250
251 const section = (kind: Kind, title: string, color: 'claude' | 'suggestion' | 'warning', empty: string) => {
252 const list = shown(kind)
253
254 return (
255 <Box key={`section:${kind}`} flexDirection="column" marginBottom={1}>
256 <Text bold color={color}>{`${title} (${list.filter(item => !item.done).length})`}</Text>
257 {list.length === 0 && <Text dimColor>{empty}</Text>}
258 {list.map(item => row(item, false))}
259 </Box>
260 )
261 }
262
263 return (
264 <Box flexDirection="column">
265 {section('todo', 'ACTION ITEMS', 'warning', 'Nothing to do yet.')}
266 {Field !== undefined && (
267 <Box key="add" marginBottom={1}>
268 <Field
269 key="add"
270 label="+ "
271 placeholder="add an action item"
272 submitLabel="add"
273 onSubmit={value => quietly($, 'add the item', () => add($, 'todo', [value], 'you'))}
274 />
275 </Box>
276 )}
277 {section('know', 'THINGS TO KNOW', 'suggestion', 'Claude pins notes here.')}
278 {section('link', 'LINKS', 'claude', 'Links from the session land here.')}
279 {done.length > 0 && (
280 <Box key="done" flexDirection="column">
281 <Button
282 key="toggle-done"
283 plain
284 dimColor
285 label={`${isDoneOpen ? '▾' : '▸'} Done (${done.length})`}
286 onPress={run('toggle Done', () => update($, showDone, open => !open))}
287 />
288 {isDoneOpen && done.map(item => row(item, true))}
289 </Box>
290 )}
291 </Box>
292 )
293 })
294}
295types/index.d.ts 27 lines1/** `link`: a URL the conversation mentioned. `know`: a thing to keep in mind. `todo`: an action item for the person. */
2export type Kind = 'link' | 'know' | 'todo'
3
4export type Item = {
5 /** `link:<url>` for links, so a second mention is the same item. */
6 id: string
7 kind: Kind
8 /** The URL for a link, the sentence otherwise. */
9 text: string
10 from: 'you' | 'claude'
11 /** Set when ticked off (todo) or dismissed (link, know): the item moves under Done. */
12 done?: boolean
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'session-sidebar': {
18 sessionId: string
19 items: Item[]
20 /** Whether the Done dropdown is open. */
21 showDone: boolean
22 /** Ids just ticked off: drawn crossed out in place for a moment before they fold into Done. */
23 settling: string[]
24 }
25 }
26}
27