Switch the desktop app's session sidebar between grouping by folder and by state in one click: a line above the prompt and /group

With many folders and sessions, switching the Code-tab sidebar between group by folder and group by state means scrolling up to its filter button every time. This mod puts the switch where it is always in view:
Group sidebar: ● Folder ○ State. Click Folder or State and the sidebar regroups at once; the filled dot shows the current grouping. Another mod's band above the prompt (claude-dj's now playing) stays, above this line./group: toggles between folder and state. /group folder and /group state pick one.The mod calls the desktop app's own sidebar tools (ccd_sidebar): list_sections to read the current grouping when the session starts, set_view with group_by to change it. That is the same setting as the filter menu, so:
The app connects its sidebar tools a moment after a session starts, so the mod keeps trying for about 30 seconds. If the sidebar still cannot be read or changed (no app window open, a terminal session), the line shows a ⚠ with the reason.
hooks/register.tsx 115 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Grouping } from '../types'
5
6const SIDEBAR = 'ccd_sidebar'
7const grouping = atom({ plugin: 'group-toggle', key: 'grouping' } as const, null as Grouping)
8const lastError = atom({ plugin: 'group-toggle', key: 'lastError' } as const, null as string | null)
9
10function textOf(content: { type: string; text?: string }[]) {
11 return content.map(block => (block.type === 'text' ? block.text ?? '' : '')).join('')
12}
13
14// The app connects its sidebar tools a moment after the session starts: until then
15// a call fails ("no connected MCP tool"). Keep trying for a while before warning.
16const RETRY_MS = 1000
17const RETRIES = 30
18
19async function refresh($: EngineInterface, attempt = 0) {
20 const retryOr = async (message: string) => {
21 if (attempt < RETRIES) {
22 $.clock.after(RETRY_MS, () => void refresh($, attempt + 1))
23 return
24 }
25 await update($, lastError, () => message)
26 }
27 try {
28 const result = await $.mcp.call(SIDEBAR, 'list_sections')
29 if (result.isError) {
30 await retryOr(textOf(result.content))
31 return
32 }
33 await update($, lastError, () => null)
34 const sections = JSON.parse(textOf(result.content)) as { id: string; group_by: string }[]
35 const by = sections.find(section => section.id === 'sessions')?.group_by
36 const now: Grouping = by === 'project' || by === 'state' ? by : 'other'
37 await update($, grouping, () => now)
38 } catch (error) {
39 await retryOr(String(error))
40 }
41}
42
43async function setGrouping($: EngineInterface, to: 'project' | 'state') {
44 let result
45 try {
46 result = await $.mcp.call(SIDEBAR, 'set_view', { group_by: to })
47 } catch (error) {
48 await update($, lastError, () => String(error))
49 $.ui.toast(`Sidebar not changed: ${String(error)}`)
50 return false
51 }
52 if (result.isError) {
53 $.ui.toast(`Sidebar not changed: ${textOf(result.content)}`)
54 return false
55 }
56 await update($, lastError, () => null)
57 await update($, grouping, () => to)
58 return true
59}
60
61export const register: Register = on => {
62 on('session.start', async ($, e, next) => {
63 await $.command.register({
64 name: 'group',
65 description: 'Group the sidebar by folder or state (no argument toggles)',
66 argumentHint: '[folder|state]',
67 })
68 void refresh($)
69 return next(e)
70 })
71
72 on('command.run', { command: 'group' }, async ($, e) => {
73 await refresh($)
74 const arg = e.args.trim().toLowerCase()
75 const to: 'project' | 'state' =
76 arg.startsWith('f') || arg.startsWith('p') ? 'project'
77 : arg.startsWith('s') ? 'state'
78 : (await read($, grouping)) === 'project' ? 'state' : 'project'
79 const ok = await setGrouping($, to)
80 return { text: ok ? `Sidebar grouped by ${to === 'project' ? 'folder' : 'state'}.` : 'Sidebar not changed.' }
81 })
82
83 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
84 const now = await read($, grouping)
85 const error = await read($, lastError)
86 if (e.props.hasSurvey) return next(e)
87
88 // Stack under whatever the other mods draw here (a music band, ...), never replace it.
89 const below = await next(e)
90 const { Box, Button, Text } = $.ui.resolve(e)
91 return (
92 <Box flexDirection="column">
93 {below}
94 <Box>
95 <Text dimColor>Group sidebar: </Text>
96 <Button
97 key="folder"
98 label={now === 'project' ? '● Folder' : '○ Folder'}
99 dimColor={now !== 'project'}
100 onPress={() => setGrouping($, 'project')}
101 />
102 <Text> </Text>
103 <Button
104 key="state"
105 label={now === 'state' ? '● State' : '○ State'}
106 dimColor={now !== 'state'}
107 onPress={() => setGrouping($, 'state')}
108 />
109 {error !== null && <Text dimColor> ⚠ {error.slice(0, 120)}</Text>}
110 </Box>
111 </Box>
112 )
113 })
114}
115types/index.d.ts 8 lines1export type Grouping = 'project' | 'state' | 'other' | null
2
3declare module 'claude-code' {
4 interface PluginState {
5 'group-toggle': { grouping: Grouping; lastError: string | null }
6 }
7}
8