A star in the desktop app's prompt footer that pins and unpins the chat in the sidebar

A star in the Claude desktop app's prompt footer. Click it to pin the chat you're in to the top of the sidebar, and click it again to unpin.
☆ not pinned
★ pinned
star-pins uses the desktop app's own sidebar tools, the ones Claude uses when you ask it to pin a chat. It reads the chat's pin with get_session, again after each reply and every 10 seconds while the chat sits idle, and changes it with set_pinned.
The change runs as a tool call, so your permission settings apply to it as they do to Claude's own tool calls. In auto mode the change is refused until you allow the tool, in the permissions block of ~/.claude/settings.json:
"allow": ["mcp__ccd_sidebar__set_pinned"]
That rule also lets Claude pin and unpin chats without asking. It only changes the sidebar.
hooks/register.tsx 135 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4const pinned = atom({ plugin: 'star-pins', key: 'pinned' } as const, null)
5const busy = atom({ plugin: 'star-pins', key: 'busy' } as const, false)
6
7// The desktop app's own servers: the same calls Claude makes with its sidebar tools.
8const SESSIONS = 'ccd_session_mgmt'
9const SET_PINNED = 'mcp__ccd_sidebar__set_pinned'
10// Catches a pin changed from the sidebar by hand while the session sits idle.
11const REFRESH_MS = 10_000
12// The button's address, which the ui.press hook below matches.
13const PIN = 'pin'
14// The state lives in the label's characters, the one thing the desktop footer
15// draws (no text styles, fills or borders; primary and secondary chips look
16// alike). A star in the footer's grey, outline while unpinned and filled while
17// pinned (the app itself records a pinned session as starred). A flag pair drew
18// too small: its font draws it so, and the footer takes no text size.
19const PINNED_ICON = '★'
20const UNPINNED_ICON = '☆'
21
22async function readPinned($: EngineInterface): Promise<boolean | null> {
23 try {
24 const result = await $.mcp.call(SESSIONS, 'get_session', { session_id: 'self' })
25 if (result.isError) return null
26 const text = result.content.find(block => block.type === 'text')?.text
27 if (text === undefined) return null
28 const value: unknown = JSON.parse(text).pinned
29 return typeof value === 'boolean' ? value : null
30 } catch {
31 return null
32 }
33}
34
35async function refresh($: EngineInterface) {
36 if (timer === undefined) {
37 timer = $.clock.every(REFRESH_MS, () => void refresh($))
38 // Once per load: a reload leaves the footer as the replaced module drew it
39 // until something redraws it.
40 $.ui.invalidate('ui.render')
41 }
42 const seen = presses
43 const value = await readPinned($)
44 // A read the sidebar answered before a press, or one taken while the change
45 // is in flight, would put the old icon back: the press's icon stands.
46 if (presses !== seen || (await read($, busy))) return
47 await update($, pinned, () => value)
48}
49
50// Started by this module's first refresh, whatever runs it; module variables
51// start over at a reload.
52let timer: Timer | undefined
53let presses = 0
54
55async function toggle($: EngineInterface) {
56 const current = await read($, pinned)
57 if (current === null || (await read($, busy))) return
58 presses += 1
59 await update($, busy, () => true)
60 // The icon turns at the press, in one redraw (a read-back after the change
61 // drew it again, and a click could look lost); a refused change turns it back.
62 await update($, pinned, () => !current)
63 const change = current ? 'unpin' : 'pin'
64 try {
65 // A tool call, run the way Claude's own calls run: made as a bare
66 // $.mcp.call, the change failed in the desktop app, while the
67 // same call from Claude worked and reads through $.mcp.call still do.
68 const outcome = await $.tool.call({
69 tool: SET_PINNED,
70 session_id: 'self',
71 pinned: !current,
72 consent: `The user pressed the pin button in the prompt footer to ${change} this session.`,
73 })
74 const reason = outcome.deny ?? (outcome.isError ? outcome.text : undefined)
75 if (reason !== undefined) {
76 await update($, pinned, () => current)
77 $.ui.toast(`The sidebar did not take the change: ${reason}`)
78 }
79 } catch (error) {
80 await update($, pinned, () => current)
81 $.ui.toast(`The sidebar did not take the change: ${error instanceof Error ? error.message : String(error)}`)
82 } finally {
83 await update($, busy, () => false)
84 }
85}
86
87export const register: Register = on => {
88 on('session.start', async ($, e, next) => {
89 const result = await next(e)
90 await refresh($)
91 return result
92 })
93
94 on('turn.complete', async ($, e, next) => {
95 const result = await next(e)
96 await refresh($)
97 return result
98 })
99
100 // Every press on the pin runs this module's code. Until the footer redraws
101 // after a reload it still shows the replaced module's button, whose own
102 // onPress runs in that unloaded module: the call fails there, and the toast
103 // says "The sidebar did not take the change". Not awaited, so the press is
104 // taken at once and never falls through to that onPress.
105 on('ui.press', { plugin: 'star-pins', element: PIN }, ($, e) => {
106 void toggle($)
107 return { element: e.element }
108 })
109
110 // The footer's mode labels, right of the prompt, not the band above it: the
111 // band holds one tree and Runway's rate-limit band never passes it on, so a
112 // button there showed or vanished with the plugins' load order. The labels
113 // drawn beneath (the engine's, any other plugin's) stay; the button leads
114 // them. The desktop draws this group after the status line's readouts
115 // (MacLoad), so there the pin sits between those and the model picker; the
116 // left side, by Auto, holds no slot a plugin draws in.
117 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
118 const state = await read($, pinned)
119 if (state === null) return next(e)
120
121 const below = await next(e)
122 const { Box, Button } = $.ui.resolve(e)
123
124 // An icon, not words, so it takes little room, given as the label, which is
125 // what the desktop draws. onPress runs only if the ui.press hook above
126 // was skipped.
127 return (
128 <Box>
129 <Button key={PIN} label={state ? PINNED_ICON : UNPINNED_ICON} onPress={() => void toggle($)} />
130 {below}
131 </Box>
132 )
133 })
134}
135types/index.d.ts 14 lines1// null until the first read, and for good where there is no desktop sidebar
2// (a terminal or cloud session): the footer is then left as it was.
3export type PinState = boolean | null
4
5declare module 'claude-code' {
6 interface PluginState {
7 'star-pins': {
8 pinned: PinState
9 // A pin change is in flight; presses wait for it.
10 busy: boolean
11 }
12 }
13}
14