Skip to content

Commit cacc558

Browse files
quickbeardclaude
andcommitted
feat(app): add a sidebar collapse toggle and a Search button to the titlebar
The sidebar became permanent chrome in #173 with no way to dismiss it, and the recents list was reachable only from its own View all row. The toggle leads the titlebar — ahead of the Windows/Linux app menu, so it is the first item on every platform. Collapsed hides the column outright rather than narrowing it to a rail; the state persists globally, so it survives a relaunch. Search sits right of Home, navigates to the recents list like View all, and focuses its search field. Collapse state lives in a fork-only context so the titlebar button and the sidebar can share it. useAppSidebar() returns undefined outside a provider rather than throwing: useV2Titlebar() is just newLayoutDesigns(), so this titlebar also renders inside the legacy shell, which has no provider and no sidebar of this kind — the toggle is hidden there by construction. Search hands off through a ?focus=search param that NewHome consumes and clears, mirroring how new-session.tsx handles ?prompt. The focus ref had to become a signal: HomeSessionSearch binds it from onMount, which runs after NewHome's effects, so the first attempt no-opped against an undefined ref and cleared the param anyway. No keyboard shortcut, so mod+b keeps its current meaning. Verified on a running build: button order Toggle(92) Home(134) Search(176); collapse and restore both track the pressed state; collapsed survives a reload; Search focuses the input both from a session and from / itself. With the new layout switched off, the legacy shell renders its own rail, no toggle, and throws nothing. 624 unit tests and the 5 smoke e2e tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 40cd28f commit cacc558

7 files changed

Lines changed: 256 additions & 135 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ file is the human-readable summary of what changed and why.
99

1010
### Desktop / Web UI
1111

12+
- **The sidebar collapses, and Search opens the recents list** — two buttons join
13+
the titlebar. The leftmost one hides and restores the sidebar, so a session can
14+
have the full window width; it remembers the choice for next launch, and it
15+
lights up while the sidebar is open. Note that Projects live only in the
16+
sidebar, so switching project means opening it again. The second, next to
17+
Home, opens the recent-sessions list exactly as **View all** does and drops the
18+
cursor straight into its search field. Neither has a keyboard shortcut, so
19+
nothing you already press has changed meaning.
20+
21+
### Desktop / Web UI
22+
1223
- **The sidebar is always on screen, and it carries your recent sessions** —
1324
the Projects list plus Settings and Help used to be a column belonging to the
1425
home screen, so opening a session left you with no navigation but the tab bar.

‎DIVERGENCES.md‎

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

‎packages/app/src/components/app-sidebar.tsx‎

Lines changed: 125 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useServerManagementController } from "@/components/dialog-select-server
1313
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
1414
import { useSettingsCommand } from "@/components/settings-dialog"
1515
import { ChannelIndicator } from "@/components/titlebar"
16+
import { useAppSidebar } from "@/context/app-sidebar"
1617
import { useGlobal } from "@/context/global"
1718
import { useLanguage } from "@/context/language"
1819
import { usePlatform } from "@/context/platform"
@@ -54,6 +55,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
5455
const global = useGlobal()
5556
const dialog = useDialog()
5657
const model = createHomeModel()
58+
const sidebar = useAppSidebar()
5759
const openSettings = useSettingsCommand()
5860
const controller = useServerManagementController({ navigateOnAdd: false })
5961
const [_state, setState, _, ready] = persisted(
@@ -66,138 +68,142 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
6668
{ initialValue: _state },
6769
)
6870

71+
// Collapsed hides the column outright rather than narrowing it to a rail, so
72+
// the content pane takes the full width. The titlebar toggle is the way back.
6973
return (
70-
<aside
71-
data-component="app-sidebar"
72-
class={`${props.class ?? ""} w-[280px] min-h-0 min-w-0 shrink-0 flex-col gap-4 overflow-hidden px-3 pb-4 pt-6`}
73-
aria-label={language.t("home.projects")}
74-
>
75-
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5">
76-
<div class="text-v2-text-text-muted [font-weight:530]">{language.t("home.projects")}</div>
77-
<Show
78-
when={
79-
global.servers.list().length === 1 &&
80-
!(model.projects().length === 0 && model.recentlyClosed().length > 0)
81-
}
82-
>
83-
<TooltipV2 placement="bottom" value={language.t("home.project.add")}>
84-
<IconButtonV2
85-
data-action="home-add-project"
86-
variant="ghost-muted"
87-
size="large"
88-
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
89-
icon={<IconV2 name="folder-add-left" />}
90-
disabled={global.servers.health[ServerConnection.key(global.servers.list()[0]!)]?.healthy === false}
91-
onClick={() => model.chooseProject(global.servers.list()[0]!)}
92-
aria-label={language.t("home.project.add")}
93-
/>
94-
</TooltipV2>
95-
</Show>
96-
</div>
97-
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
98-
<Show
99-
when={global.servers.list().length > 1}
100-
fallback={
101-
<Show
102-
when={model.projects().length > 0}
103-
fallback={
104-
<HomeProjectEmpty
74+
<Show when={!sidebar?.collapsed()}>
75+
<aside
76+
data-component="app-sidebar"
77+
class={`${props.class ?? ""} w-[280px] min-h-0 min-w-0 shrink-0 flex-col gap-4 overflow-hidden px-3 pb-4 pt-6`}
78+
aria-label={language.t("home.projects")}
79+
>
80+
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5">
81+
<div class="text-v2-text-text-muted [font-weight:530]">{language.t("home.projects")}</div>
82+
<Show
83+
when={
84+
global.servers.list().length === 1 &&
85+
!(model.projects().length === 0 && model.recentlyClosed().length > 0)
86+
}
87+
>
88+
<TooltipV2 placement="bottom" value={language.t("home.project.add")}>
89+
<IconButtonV2
90+
data-action="home-add-project"
91+
variant="ghost-muted"
92+
size="large"
93+
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
94+
icon={<IconV2 name="folder-add-left" />}
95+
disabled={global.servers.health[ServerConnection.key(global.servers.list()[0]!)]?.healthy === false}
96+
onClick={() => model.chooseProject(global.servers.list()[0]!)}
97+
aria-label={language.t("home.project.add")}
98+
/>
99+
</TooltipV2>
100+
</Show>
101+
</div>
102+
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
103+
<Show
104+
when={global.servers.list().length > 1}
105+
fallback={
106+
<Show
107+
when={model.projects().length > 0}
108+
fallback={
109+
<HomeProjectEmpty
110+
server={global.servers.list()[0]!}
111+
recentlyClosed={model.recentlyClosed()}
112+
homedir={model.homedir()}
113+
chooseProject={model.chooseProject}
114+
openRecentProject={(conn, directory) => model.addProjects(conn, [directory])}
115+
language={language}
116+
/>
117+
}
118+
>
119+
<HomeProjectList
105120
server={global.servers.list()[0]!}
106-
recentlyClosed={model.recentlyClosed()}
107-
homedir={model.homedir()}
108-
chooseProject={model.chooseProject}
109-
openRecentProject={(conn, directory) => model.addProjects(conn, [directory])}
121+
projects={model.projects()}
122+
selected={model.selection()}
123+
selectProject={model.selectProject}
124+
openNewSession={model.openProjectNewSession}
125+
editProject={model.editProject}
126+
closeProject={model.closeProject}
127+
clearNotifications={model.clearNotifications}
128+
unseenCount={model.unseenCount}
110129
language={language}
111130
/>
112-
}
113-
>
114-
<HomeProjectList
115-
server={global.servers.list()[0]!}
116-
projects={model.projects()}
117-
selected={model.selection()}
118-
selectProject={model.selectProject}
119-
openNewSession={model.openProjectNewSession}
120-
editProject={model.editProject}
121-
closeProject={model.closeProject}
122-
clearNotifications={model.clearNotifications}
123-
unseenCount={model.unseenCount}
124-
language={language}
125-
/>
126-
</Show>
127-
}
128-
>
129-
<div class="flex min-w-0 flex-col gap-4">
130-
<For each={global.servers.list()}>
131-
{(item) => {
132-
const key = ServerConnection.key(item)
133-
const healthy = () => !!global.servers.health[key]?.healthy
134-
const serverCtx = global.ensureServerCtx(item)
135-
const projects = () => serverCtx.projects.list()
136-
const hasProjects = () => projects().length > 0
137-
const collapsed = () => !!state().collapsed[key]
138-
return (
139-
<div class="flex min-w-0 flex-col gap-1">
140-
<HomeServerRow
141-
server={item}
142-
selected={model.selection().server === key && !model.selection().directory}
143-
collapsed={collapsed()}
144-
health={global.servers.health[key]}
145-
controller={controller}
146-
focusServer={model.focusServer}
147-
chooseProject={model.chooseProject}
148-
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
149-
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
150-
language={language}
151-
/>
152-
<Show when={healthy() && hasProjects() && !collapsed()}>
153-
<div class="mx-3 h-px bg-v2-border-border-base" />
154-
<HomeProjectList
131+
</Show>
132+
}
133+
>
134+
<div class="flex min-w-0 flex-col gap-4">
135+
<For each={global.servers.list()}>
136+
{(item) => {
137+
const key = ServerConnection.key(item)
138+
const healthy = () => !!global.servers.health[key]?.healthy
139+
const serverCtx = global.ensureServerCtx(item)
140+
const projects = () => serverCtx.projects.list()
141+
const hasProjects = () => projects().length > 0
142+
const collapsed = () => !!state().collapsed[key]
143+
return (
144+
<div class="flex min-w-0 flex-col gap-1">
145+
<HomeServerRow
155146
server={item}
156-
projects={projects()}
157-
selected={model.selection()}
158-
selectProject={model.selectProject}
159-
openNewSession={model.openProjectNewSession}
160-
editProject={model.editProject}
161-
closeProject={model.closeProject}
162-
clearNotifications={model.clearNotifications}
163-
unseenCount={model.unseenCount}
147+
selected={model.selection().server === key && !model.selection().directory}
148+
collapsed={collapsed()}
149+
health={global.servers.health[key]}
150+
controller={controller}
151+
focusServer={model.focusServer}
152+
chooseProject={model.chooseProject}
153+
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
154+
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
164155
language={language}
165156
/>
166-
</Show>
167-
</div>
168-
)
169-
}}
170-
</For>
171-
</div>
172-
</Show>
173-
</ScrollView>
157+
<Show when={healthy() && hasProjects() && !collapsed()}>
158+
<div class="mx-3 h-px bg-v2-border-border-base" />
159+
<HomeProjectList
160+
server={item}
161+
projects={projects()}
162+
selected={model.selection()}
163+
selectProject={model.selectProject}
164+
openNewSession={model.openProjectNewSession}
165+
editProject={model.editProject}
166+
closeProject={model.closeProject}
167+
clearNotifications={model.clearNotifications}
168+
unseenCount={model.unseenCount}
169+
language={language}
170+
/>
171+
</Show>
172+
</div>
173+
)
174+
}}
175+
</For>
176+
</div>
177+
</Show>
178+
</ScrollView>
174179

175-
<SidebarRecents
176-
records={model.records()}
177-
server={model.selection().server}
178-
loading={model.sessionLoad.isLoading}
179-
canCreate={!!model.newSessionProject()}
180-
openNewSession={model.openNewSession}
181-
openSession={model.openSession}
182-
/>
180+
<SidebarRecents
181+
records={model.records()}
182+
server={model.selection().server}
183+
loading={model.sessionLoad.isLoading}
184+
canCreate={!!model.newSessionProject()}
185+
openNewSession={model.openNewSession}
186+
openSession={model.openSession}
187+
/>
183188

184-
<HomeUtilityNav
185-
class="flex shrink-0"
186-
openSettings={openSettings}
187-
openHelp={() => platform.openLink(DOCS_URL)}
188-
language={language}
189-
/>
189+
<HomeUtilityNav
190+
class="flex shrink-0"
191+
openSettings={openSettings}
192+
openHelp={() => platform.openLink(DOCS_URL)}
193+
language={language}
194+
/>
190195

191-
{/* The DEV / BETA badge, moved out of the titlebar so Home can lead it.
196+
{/* The DEV / BETA badge, moved out of the titlebar so Home can lead it.
192197
The condition mirrors ChannelIndicator's own — kept here rather than
193198
wrapping unconditionally so prod, where it renders nothing, does not
194199
leave the column's gap-4 behind as dead space. */}
195-
<Show when={CHANNEL === "dev" || CHANNEL === "beta"}>
196-
<div class="flex shrink-0 pl-1.5">
197-
<ChannelIndicator debugTools={props.debugTools} />
198-
</div>
199-
</Show>
200-
</aside>
200+
<Show when={CHANNEL === "dev" || CHANNEL === "beta"}>
201+
<div class="flex shrink-0 pl-1.5">
202+
<ChannelIndicator debugTools={props.debugTools} />
203+
</div>
204+
</Show>
205+
</aside>
206+
</Show>
201207
)
202208
}
203209

‎packages/app/src/components/titlebar.tsx‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { usePlatform } from "@/context/platform"
1616
import { useCommand } from "@/context/command"
1717
import { useLanguage } from "@/context/language"
1818
import { useSettings } from "@/context/settings"
19+
import { useAppSidebar } from "@/context/app-sidebar"
1920
import { WindowsAppMenu } from "./windows-app-menu"
2021
import { applyPath, backPath, forwardPath } from "./titlebar-history"
2122
import { TitlebarTabStrip } from "@/components/titlebar-tab-strip"
@@ -79,6 +80,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
7980
const location = useLocation()
8081
const params = useParams()
8182
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
83+
// Fork: undefined in the legacy shell, which renders this titlebar without an
84+
// AppSidebarProvider and has no sidebar of this kind to toggle.
85+
const appSidebar = useAppSidebar()
8286
const mobile = createMediaQuery("(max-width: 767px)")
8387
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
8488

@@ -475,8 +479,27 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
475479
}}
476480
>
477481
{/* Fork: the channel badge moved to the bottom of the sidebar
478-
(components/app-sidebar.tsx) so Home leads the titlebar. The
479-
legacy titlebar below still renders it inline. */}
482+
(components/app-sidebar.tsx) so this row leads with the
483+
sidebar toggle. The legacy titlebar below still renders the
484+
badge inline. */}
485+
<Show when={appSidebar}>
486+
{(sidebar) => (
487+
<TooltipV2 placement="bottom" value={language.t("command.sidebar.toggle")} class="shrink-0">
488+
<IconButtonV2
489+
type="button"
490+
data-action="titlebar-sidebar-toggle"
491+
variant="ghost-muted"
492+
size="large"
493+
class="!w-9 shrink-0"
494+
icon={<IconV2 name="sidebar-right" />}
495+
state={sidebar().collapsed() ? undefined : "pressed"}
496+
onClick={() => sidebar().toggle()}
497+
aria-label={language.t("command.sidebar.toggle")}
498+
aria-pressed={!sidebar().collapsed()}
499+
/>
500+
</TooltipV2>
501+
)}
502+
</Show>
480503
<Show when={windows() || linux()}>
481504
<WindowsAppMenu command={command} platform={platform} variant="v2" />
482505
</Show>
@@ -502,6 +525,21 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
502525
aria-pressed={layout.route().type === "draft"}
503526
/>
504527
</TooltipV2>
528+
{/* Fork: opens the recent-sessions list, like the sidebar's
529+
"View all", and focuses its search field on arrival. */}
530+
<TooltipV2 placement="bottom" value={language.t("home.sessions.search.placeholder")} class="shrink-0">
531+
<IconButtonV2
532+
type="button"
533+
data-action="titlebar-search"
534+
variant="ghost-muted"
535+
size="large"
536+
class="!w-9 shrink-0"
537+
icon={<IconV2 name="magnifying-glass" />}
538+
state={layout.route().type === "home" ? "pressed" : undefined}
539+
onClick={() => navigate("/?focus=search")}
540+
aria-label={language.t("home.sessions.search.placeholder")}
541+
/>
542+
</TooltipV2>
505543

506544
<TitlebarTabStrip
507545
tabs={tabsStore}

0 commit comments

Comments
 (0)