Skip to content
This repository was archived by the owner on Jun 16, 2026. It is now read-only.

feat(ui): adaptive UI phase 3 — collapsible sidebar + focus mode (bits-ui) - #5

Merged
Adam Poulemanos (bashandbone) merged 12 commits into
mainfrom
claude/adaptive-ui-phase-3-sidebar-focus
Apr 15, 2026
Merged

feat(ui): adaptive UI phase 3 — collapsible sidebar + focus mode (bits-ui)#5
Adam Poulemanos (bashandbone) merged 12 commits into
mainfrom
claude/adaptive-ui-phase-3-sidebar-focus

Conversation

@bashandbone

Copy link
Copy Markdown
Contributor

Summary

Third of five PRs for spec 002 — Adaptive UI. Stacked on #4 (Phase 2). Will auto-retarget main as earlier phases land.

Two independent but complementary UI modes layered on the Phase 1/2 foundation:

P3-A — Collapsible sidebar (bits-ui@^2.17.3)

  • Sidebar.svelte wrapped in <Collapsible.Root> / <Collapsible.Content forceMount>. Width animates between 260 px and 0 px via bits-ui's data-state="open"|"closed"; prefers-reduced-motion kills the transition.
  • New HamburgerButton.svelte — 32×32 always-visible toggle anchored top-left of .main-panel (outside the Collapsible subtree so it survives the collapse). aria-expanded mirrors state; aria-label varies ("Collapse" vs "Expand").
  • Hover-peek overlay: when collapsed, a 48 px transparent hotzone on the left edge peeks the sidebar as position: absolute so it overlays content (does not compress the content area). A 300 ms leave-delay + mouseenter/leave handlers on the <aside> itself keep the peek alive as the cursor crosses from hotzone onto the sidebar body.
  • Persistence: workspace.ui.sidebar_collapsed (no schema change — ui is freeform JSON).

P3-B — Focus mode

  • focusMode: 'none' | 'single' | 'split-two' + focusSessionIds: number[]. single hides both sidebars and fills the content area with one session's SplitView. split-two is declared for forward-compat with Phase 4 but not wired in Phase 3 — a persisted split-two state degrades to single on the first id and writes back the degraded value for restart idempotency.
  • AlertBar relocated to the top of .main-panel (was inside .session-panel). Alerts remain visible in focus mode — NNGroup 2019 Zen Mode finding, cited in research.md §B.5.
  • Compact breadcrumb chip (● Project / session-label · status) in focus mode. Dot colour from getProjectColor (Phase 2).
  • Always-visible × exit button (position: absolute; top-right; z-index 60). Critical: xterm captures keyboard Escape, so a clickable exit is required.
  • maximise button in the non-focus header enters single focus.
  • Keyboard:
    • Ctrl+Shift+F toggles single focus, guarded by isEditableTarget.
    • Escape exits focus mode, also guarded by isEditableTarget. Since isEditableTarget matches .xterm-helper-textarea, Escape inside the terminal is preserved for xterm's own handling (Claude Code uses Escape to interrupt).
  • Persistence: workspace.ui.focus_mode and focus_mode_session_ids.

Commits

  • 763424f feat(ui): phase 3 adaptive-ui — collapsible sidebar + focus mode (bits-ui)
  • fa6b066 fix(ui): address phase 3 review feedback

Review fixes (commit fa6b066)

From rigorous independent review:

  1. Peek-zone rewired — a separate .sidebar-peek-zone div was occluded by the sidebar overlay (z-index battle) and its events never fired; crossing hotzone → sidebar auto-closed the peek in 300 ms. Zone removed; hover handlers now on the <aside> itself.
  2. Hamburger hidden in focus mode — in focus mode the sidebar is force-hidden via CSS; clicking the hamburger silently flipped persisted state. Hamburger + hotzone now only render when focusMode === 'none'.
  3. split-two downgrade persisted — restored split-two → memory single, but persisted stayed split-two. setUi now writes back on degrade.
  4. Hamburger ↔ AlertBar overlap — new .alert-bar-frame.with-hamburger reserves 44 px left padding when the hamburger is visible.

Nits: HamburgerButton aria-label varies with state; peek hotzone no longer misleadingly uses cursor: pointer.

Test plan

  • pnpm check — 0 errors / 0 warnings (826 files)
  • pnpm test — 48/48 across 8 files (4 new HamburgerButton cases)
  • cargo check -p tend-protocol -p tend-cli — clean (no Rust touched)
  • Manual: sidebar collapse animation, persistence across reload
  • Manual: peek overlay stays open while cursor is on sidebar body (the rewired path)
  • Manual: Escape inside xterm is NOT intercepted (critical — would break Claude Code's ESC-to-interrupt)
  • Manual: focus mode visually hides sidebars, AlertBar stays visible, × clickable while xterm focused

Process note

Two implementation agents timed out mid-work (stream idle timeouts ~ 5 min each). P3-A's Sidebar Collapsible wrap + HamburgerButton.svelte landed from the second agent; the rest (wiring, P3-B, tests) was completed in the foreground. The uncommitted state was handed to each agent as explicit context. After completion an independent review agent flagged the four issues above, all addressed in fa6b066.

Deliberately not done

  • split-two focus mode — type kept in place; entry paths wait for Phase 4's multi-pane.
  • No backend changes. No new dependencies beyond bits-ui.
  • Command palette (Ctrl+K) lives in Phase 4 per plan.

Diff

 6 files changed, ~820 insertions, ~75 deletions

https://claude.ai/code/session_01Tynwwyo146dMu2McVRjgc8

Adds specs/002-adaptive-ui/ with three documents:

research.md — captures findings from two parallel research tracks:
  (A) Svelte 5 UI library landscape: paneforge (resizable panes),
  svelte-dnd-action (sortable DnD), vanilla-colorful (colour picker),
  bits-ui (headless tabs/collapsible), @neodrag/svelte (future free-drag).
  (B) UX patterns from VS Code, tmux, Slack, Grafana, Datadog, and NNGroup
  research covering session overflow, active/inactive indicators, pane
  identification, alert→pane navigation, and focus mode.

spec.md — full feature specification for:
  §1 Project colour coding (auto-palette, settings_json, vanilla-colorful picker)
  §2 Multi-pane session workspace (paneforge, svelte-dnd-action, 520 px min,
     overflow indicator, quick-switch palette Ctrl+K, project auto-fill)
  §3 Focus mode (single/split-two, AlertBar persists, breadcrumb chip, × exit)
  §4 Refresh button spinners (SpinnerIcon.svelte, uniform pattern)
  §5 Multi-view tabs (Sessions/Workspace/Overview via bits-ui Tabs)
  §6 State persistence + ghost session restore (PaneSlot schema extension,
     Restart button re-runs metadata.command[], no schema migration needed)
  §7 Collapsible sidebar (bits-ui Collapsible, hover-peek overlay, 200 ms slide)
  §8 Session identification (active indicator, hover-to-highlight, click-to-flash,
     AlertBar scroll + flash improvements)
  §9 CSS architecture for project colours (--project-color custom property)

plan.md — 5-phase implementation plan with file-change table, dependency
  installation steps, risk register, and testing notes.

No code changes in this commit — spec only.

https://claude.ai/code/session_01Tynwwyo146dMu2McVRjgc8
Implements Phase 1 of specs/002-adaptive-ui/plan.md — five small, additive
quick wins that lay groundwork for later phases without pulling in any new
dependencies or backend changes. Project colour values are NOT populated yet
(Phase 2's job); every new `--project-color` reference falls back cleanly to
`--color-accent` via `var(--project-color, var(--color-accent))`.

P1-A — Active Session Indicator
- SessionRow: `active` + `anyActive` props. Active row paints a 2 px
  --project-color left border, an 8 % colour-mix background tint, and a
  6 px dot `::before` on the label row. `data-session-id` attribute
  added to support P1-B scroll targeting.
- SessionList: `activeSessionIds: Set<number>` prop, passed per row.
- +page: derives `activeSessionIds = activeSessionId ? new Set([id]) : new Set()`.
  Phase 4 expands this to the full visible-slot set.
- Non-active rows' `.session-main` dim to 70 % opacity when any row is
  active; badges stay fully opaque (they live in `.session-meta`).

P1-B — AlertBar "Go to" flash + scroll
- SplitView: `highlighted: boolean` prop triggers a 1.5 s
  `flash-border` CSS animation on the component's outer border (also
  --project-color). Respects `prefers-reduced-motion` (animation
  suppressed, border left transparent).
- +page: `highlightSessionId` state set in `handleActivateSession`,
  cleared after 1500 ms via `setTimeout`. Timer is cleaned up on
  unmount. Passed to SplitView as
  `highlighted={highlightSessionId === activeSessionId}`.
- AlertBar: "Go to" now calls `onActivateSession` and dispatches a
  `tend:session-scroll-to` CustomEvent on `window`.
- SessionList: `$effect` registers a `tend:session-scroll-to` listener
  that looks up `[data-session-id="…"]` and calls `scrollIntoView`.
  Listener is removed on teardown.

P1-C — Filter Focus Shortcut (`/`)
- SessionList: exports `focusFilter()` instance method (Svelte 5
  `export function` in instance `<script>`). Parent uses `bind:this`.
- New util `$lib/util/isEditableTarget.ts`: guards the shortcut so it
  never fires when the keystroke originates from an `<input>`,
  `<textarea>`, `[contenteditable]` element, or anything inside xterm's
  hidden `.xterm-helper-textarea` (critical — otherwise `/` would steal
  keystrokes from the embedded terminal).
- +page: `<svelte:window onkeydown>` dispatches `/` to `focusFilter()`.

P1-D — Refresh button spinners
- New component `SpinnerIcon.svelte`: 14 px CSS-only rotating arc
  (border + border-top-color + @Keyframes). `size` and `label` props.
  Under `prefers-reduced-motion` it collapses to a static filled dot
  so the indicator still communicates "busy" without motion.
- CrossProjectOverview: Refresh button swaps its label for a
  SpinnerIcon while `overviewStore.loading`. Button is disabled and
  has a fixed `min-width` so the swap doesn't shift layout.
- LayoutSwitcher: new `refreshing` local state wraps `refresh()`
  (true before `await`, false in `finally`). Dropdown shows a
  SpinnerIcon in a new header strip while refreshing. When the list is
  empty and loading, the empty-state message reads "Loading…" with the
  spinner; the "No saved layouts" copy is preserved for the real empty
  case.

P1-E — Dark-theme warning colour
- app.css: `--color-warning-bg: #3d2e00` and `--color-warning: #fbbf24`
  under `:root` — dark-compatible, keeps salience on the dark surface.
- AlertBar: replaced hardcoded light-mode fallbacks (#fef3c7, #f59e0b,
  #6b7280, #1d4ed8, #92400e, #fde68a) with the dark-compatible CSS
  variables. Hover states use `color-mix(... transparent)` so they
  tint the warning strip without washing it out.

Tests
- `src/lib/util/isEditableTarget.test.ts`: unit tests for the guard —
  null, non-Element, `<input>`, `<textarea>`, `[contenteditable]`,
  non-editable elements, and the xterm `.xterm-helper-textarea`
  subtree check (critical for not hijacking terminal keystrokes).
- `src/lib/components/SpinnerIcon.test.ts`: renders the component with
  `svelte`'s `mount()` into jsdom, asserts `role="status"`,
  `aria-label`, and the `--spinner-size` custom property for default
  + custom size + custom label.
- `vitest.config.ts`: adds `resolve.conditions: ["browser"]` so
  vite-plugin-svelte resolves the client entry (Svelte 5's `mount()`
  is not available from the server build — tests would otherwise fail
  with `lifecycle_function_unavailable`).

Acceptance
- `pnpm check`: 0 errors, 0 warnings (353 files).
- `pnpm test`: 18 passed (3 files) — includes the two new suites.
- `cargo check -p tend-protocol -p tend-cli`: both green. (The
  `tend-workbench` crate requires `pango` system libs not present in
  the local sandbox; Phase 1 does not touch any Rust code.)

https://claude.ai/code/session_01Tynwwyo146dMu2McVRjgc8
- Click-same-session re-flashes: replace `highlighted: boolean` with a
  monotonic `highlightToken: number`. +page.svelte increments on every
  activation; SplitView's $effect toggles its `flashing` class off then
  on via requestAnimationFrame so the CSS animation restarts even when
  the same session id is re-clicked.
- Drop the always-on 2 px transparent border on `.split-view` that was
  permanently shrinking the xterm container. Moved to a
  `::before` overlay (`position: absolute; inset: 0; pointer-events:
  none`) so xterm now gets the full pane in every dimension.
- Move the active-session dot from `.session-label-row::before` to
  `.session-row::before` per spec §8.1 so the dot sits in the row's
  outer gutter, not nested inside the label flex row.
- New integration tests (7 added; total 18 → 25):
  - SessionRow.test.ts: active/dimmed classes + data-session-id.
  - AlertBar.test.ts: Go-to button dispatches tend:session-scroll-to
    CustomEvent with the right detail and calls onActivateSession.
  - SessionList.test.ts: dispatching tend:session-scroll-to on the
    window calls Element.prototype.scrollIntoView with the expected
    options; no-op for unknown session ids.
- Phase-4 TODO comment on the scroll-to listener in SessionList noting
  that a second SessionList instance will need listener scoping.
- Nits also addressed:
  - AlertBar now uses `interface Props; let {…}: Props = $props()` to
    match the rest of the codebase.
  - LayoutSwitcher suppresses the header spinner when the list is
    empty (the empty-state spinner already covers that case).
  - Update stale `#713f12` fallback literals in SessionRow and
    +page.svelte to `#3d2e00` to match the new `--color-warning-bg`
    root value. AgentPane.svelte intentionally left alone — reviewer
    did not flag it and it's not a Phase 1 file.

Verified: pnpm check 0/0, pnpm test 25/25.

https://claude.ai/code/session_01Tynwwyo146dMu2McVRjgc8
Implements §1 of specs/002-adaptive-ui/spec.md:

Backend (P2-A):
- Add `color: Option<String>` to ProjectSettings with serde skip-if-none.
- `ProjectService::register` now auto-assigns a palette colour derived
  from `(id - 1) % 12` so the first project gets `#60a5fa` (blue, matching
  the legacy accent). Deterministic and stable per-DB.
- 12-colour palette exported from `project::COLOR_PALETTE` for reuse by
  future features (layout import, CLI tooling).
- New contract tests cover single-project assignment and three-project
  cycling through the palette.

Frontend (P2-B/C/D):
- `ProjectSettings.color?: string` in the TypeScript API.
- New `ColorSwatchPicker.svelte` — popover wrapping vanilla-colorful's
  `<hex-color-picker>` custom element. SSR-safe dynamic import, Escape
  and click-outside dismissal, dark-theme styling via shadow parts.
- Sidebar: every project item carries `--project-color`. Selected-state
  left-border now uses that variable. An always-visible colour swatch
  button opens the picker; writes merge into existing settings so
  `retention_days` survives.
- SessionList + SessionRow: threads `--project-color` through the row
  root and group heading. Group headings gain a 2 px left strip; active
  rows now flash the real project colour (Phase 1 plumbed the variable;
  Phase 2 fills it in).
- Active-session header + SplitView wrapper: 4 px left strip + 8%
  `color-mix` background tint. The Phase 1 flash overlay and any future
  CompanionPane header inherit via the CSS cascade.

Tests:
- `ColorSwatchPicker.test.ts` — 4 smoke tests (mount, custom element
  present, color-changed → onChange, Escape → onClose).
- `SessionRow.test.ts` — 2 new assertions for the `--project-color`
  inline style (present when colour is set, absent otherwise).
- `cargo test -p tend-workbench` could not be executed locally
  (system-level GTK/atk libs unavailable in this sandbox); `cargo check
  -p tend-protocol -p tend-cli` passes. The new Rust tests are pure
  sqlx-over-in-memory-sqlite and should run anywhere the existing
  project_register contract suite runs.

pnpm check: 0 errors / 0 warnings (362 files).
pnpm test: 31/31 passing (was 27).

New package: `vanilla-colorful` (2.7 KB, zero deps).

https://claude.ai/code/session_01Tynwwyo146dMu2McVRjgc8
- Debounce colour-picker drag writes to 200 ms trailing
- Validate DB-sourced hex colours at consumer boundary
- Fix swatch re-click reopening instead of toggling
- Nits: remove redundant .max(0), undefined→'', comment clarity

https://claude.ai/code/session_01Tynwwyo146dMu2McVRjgc8
…s-ui)

Installs bits-ui@^2.17.3 and layers two independent but complementary UI
modes on top of the Phase 1/2 foundation.

## P3-A — Collapsible sidebar

- `Sidebar.svelte` now renders inside `<Collapsible.Root>` / `<Collapsible.Content forceMount>`.
  Width animates between 260 px and 0 px via bits-ui's `data-state="open"|"closed"`
  attributes; `@media (prefers-reduced-motion: reduce)` kills the transition.
- `HamburgerButton.svelte` (new) — 32×32 always-visible trigger floated at the
  top-left of `.main-panel` via `.hamburger-slot` (z-index 60, outside the
  Collapsible subtree so it survives the close). aria-expanded mirrors open
  state; aria-controls points at the Content id. Stateless — parent owns the
  bool.
- Hover-peek overlay: when collapsed, a 48 px transparent `.sidebar-peek-hotzone`
  on the left edge triggers `peeking=true`. The sidebar re-anchors to
  `position: absolute` (via `data-peeking="true"`) so it overlays the content
  rather than compressing it. 300 ms leave-delay + a full-width `.sidebar-peek-zone`
  on peek prevent flicker when the cursor crosses onto the peeked sidebar.
- Persistence: `sidebar_collapsed` lives in `workspace.ui`, hydrated on mount,
  written via `setUi` on every toggle. No backend change (ui is freeform JSON).

## P3-B — Focus mode

- State: `focusMode: 'none' | 'single' | 'split-two'` + `focusSessionIds: number[]`.
  `single` hides both sidebars and fills the content area with one session's
  SplitView. `split-two` is declared for forward-compat with Phase 4's
  multi-pane work but entry paths aren't wired in Phase 3 — a persisted
  split-two state degrades to single on the first id.
- `.app-layout.focus-mode` hides `.sidebar-collapsible` AND `.session-panel`
  with matching 200 ms width+opacity transitions (reduced-motion aware).
- AlertBar relocated from inside `.session-panel` to the top of `.main-panel`.
  **Alerts must never be hidden** in focus mode — NNGroup 2019 Zen Mode finding
  cited in research.md §B.5.
- Compact breadcrumb chip (`● Project / session-label · status`) at the top of
  `.content-panel`. Dot colour comes from `getProjectColor` (Phase 2) so the
  project identity signal survives focus mode. Matches the Obsidian breadcrumb
  pattern.
- Always-visible `×` exit button (`position: absolute; top-right; z-index 60`).
  Critical per research §B.5: xterm captures keyboard Escape; a clickable exit
  is required.
- `⊙` enter button in `.active-session-header` (non-focus-mode) — opens single
  focus on the active session.
- Keyboard shortcuts on `<svelte:window>`:
  * `Ctrl+Shift+F` — toggle single focus on the active session. Guarded with
    `isEditableTarget` so typing in inputs doesn't trigger.
  * `Escape` — exit focus mode. **CRITICALLY** guarded with `isEditableTarget`
    which also matches xterm's `.xterm-helper-textarea` — Escape inside the
    terminal is preserved for xterm's own handling (Claude Code uses Escape
    to interrupt).
- Persistence: `focus_mode` + `focus_mode_session_ids` in `workspace.ui`.

## Tests

- New `HamburgerButton.test.ts` — 4 cases: aria-expanded open, aria-expanded
  closed, onToggle(false) when open, onToggle(true) when closed.
- Full suite: 48/48 (was 44/44 after Phase 2).

## Verification

- `pnpm check` — 0 errors / 0 warnings (825 files)
- `pnpm test` — 48/48 across 8 files
- `cargo check -p tend-protocol -p tend-cli` — clean (no Rust touched)

https://claude.ai/code/session_01Tynwwyo146dMu2McVRjgc8
Four recommended fixes from independent review of 763424f:

1. **Peek-zone mechanism rewired** — the separate `.sidebar-peek-zone` div
   (z-index 40) was occluded by the sidebar overlay (z-index 50), so its
   mouseenter/leave events never fired in practice. Result: the cursor
   crossing from the 48 px hotzone onto the peeked sidebar body started a
   300 ms close timer that nothing cancelled, closing the peek while the
   user was still hovering it.

   The zone div is removed entirely. `Sidebar.svelte` gained
   `onPeekEnter`/`onPeekLeave` props bound directly to the `<aside>`
   element, so the sidebar's own hover events keep the peek alive. The
   handlers are plumbed through from `+page.svelte`.

2. **Focus-mode + hamburger UX** — in focus mode the sidebar is
   deliberately hidden via CSS. Clicking the hamburger still flipped the
   persisted `sidebar_collapsed` state silently (no visual feedback). The
   hamburger (and its hotzone) now only render when `focusMode === 'none'`.

3. **split-two persisted-state downgrade is written back** — if
   `workspace.ui.focus_mode === 'split-two'` is restored from disk, it
   degrades to `'single'` in memory but the previous diff left the
   persisted value as `'split-two'`, so restart was not idempotent.
   `setUi` is now called for both `focus_mode` and `focus_mode_session_ids`
   inside the downgrade branch.

4. **Hamburger ↔ AlertBar overlap** — hamburger at `top: 4; left: 4; 32×32`
   sat over the first ~36 px of `.main-panel`, which is where AlertBar
   starts. When an alert was visible, the first `1 rem` of its content sat
   under the hamburger. New `.alert-bar-frame.with-hamburger` wrapper
   reserves `padding-left: 44 px` (32 px button + 8 px margin) only when
   the hamburger is visible.

Nits:
- HamburgerButton aria-label now varies with state
  ("Collapse projects sidebar" vs "Expand projects sidebar") for clearer
  assistive-tech feedback. Test updated.
- Dropped `cursor: pointer` on the peek hotzone (misleading affordance;
  it only peeks on hover, doesn't click).

Verification:
- `pnpm check`: 0 errors / 0 warnings (826 files)
- `pnpm test`: 48/48 across 8 files

https://claude.ai/code/session_01Tynwwyo146dMu2McVRjgc8
Copilot AI review requested due to automatic review settings April 15, 2026 18:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements Phase 3 of the Adaptive UI spec by introducing a collapsible/peekable project sidebar (via bits-ui Collapsible) and a “focus mode” that visually hides side panels while keeping the primary session content prominent.

Changes:

  • Added controlled bits-ui Collapsible wrapper around the Sidebar, plus hover-peek overlay behavior and persisted collapsed state.
  • Introduced focus mode (none|single|split-two w/ Phase-3 downgrade behavior) with keyboard shortcuts and an always-clickable exit affordance.
  • Added a new HamburgerButton component with unit tests; updated dependencies (bits-ui) and lockfile.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/routes/+page.svelte Wires collapsible sidebar + hover-peek, focus mode state/shortcuts, AlertBar relocation, and layout/CSS changes
src/lib/components/Sidebar.svelte Wraps sidebar content in bits-ui Collapsible and adds peeking overlay styling + hover handlers
src/lib/components/HamburgerButton.svelte New stateless toggle button component (ARIA + click forwarding)
src/lib/components/HamburgerButton.test.ts New tests asserting HamburgerButton’s ARIA contract and toggle callback behavior
package.json Adds bits-ui dependency
pnpm-lock.yaml Locks bits-ui and its transitive dependencies
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/routes/+page.svelte
Comment on lines 305 to 314
<Sidebar
{selectedProjectId}
onSelectProject={handleSelectProject}
onSpawnSession={(project) => openSpawnDialog(project)}
open={!sidebarCollapsed || sidebarPeeking}
peeking={sidebarCollapsed && sidebarPeeking}
contentId="sidebar-collapsible-content"
onPeekEnter={onPeekEnter}
onPeekLeave={onPeekLeave}
/>

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In focus mode, the Sidebar is only visually collapsed via CSS, but the component still receives open={!sidebarCollapsed || sidebarPeeking}. That means aria-hidden can remain false and the (forceMounted) sidebar content may still be reachable to screen readers / keyboard tabbing while supposedly hidden. Consider gating the open/peeking props by focusMode === 'none' (or adding a hidden/inert prop) so focus mode truly removes the sidebar from the accessibility tree and tab order.

Copilot uses AI. Check for mistakes.
Comment thread src/routes/+page.svelte
Comment on lines +355 to +389
<div class="main-panel-body">
<div class="session-panel">
<div class="session-panel-header">
<button
class="overview-btn"
onclick={() => { overviewOpen = !overviewOpen; activeSessionId = null; scratchpadStore.clear(); workspaceStore.update({ focused_session_id: null }); }}
title="Cross-project reminder overview"
aria-label="Open reminder overview"
class:active={overviewOpen}
>
<SplitView
sessionId={activeSessionId}
session={activeSession}
highlightToken={highlightSessionId === activeSessionId ? highlightToken : 0}
/>
</div>
{/key}
{:else}
<div class="empty-content">
<h2>tend</h2>
<p class="muted">
Select a session from the list to view its terminal output.
</p>
{#if projectsStore.activeProjects.length === 0 && !projectsStore.loading}
Overview
</button>
<LayoutSwitcher onMissingSessions={(ids) => { missingSessions = new Set(ids); }} />
<button
class="settings-btn"
onclick={() => settingsOpen = true}
title="Notification settings"
aria-label="Open notification settings"
>
Settings
</button>
</div>
<SessionList
bind:this={sessionListRef}
{selectedProjectId}
{missingSessions}
{activeSessionIds}
onActivateSession={handleActivateSession}
onSpawnSession={() => openSpawnDialog(
selectedProjectId !== null
? projectsStore.byId(selectedProjectId) ?? null
: null,
)}
/>
</div>

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When focusMode !== 'none', .session-panel is collapsed with width: 0/opacity: 0 but it remains in the DOM with focusable controls (Overview/Settings inputs, SessionList rows). Without inert/aria-hidden (or conditional unmount), users can still tab into hidden UI and trigger state changes, and screen readers may still announce it. Add aria-hidden + inert (and/or pointer-events: none) for the session panel while focus mode is active.

Copilot uses AI. Check for mistakes.
Comment thread src/routes/+page.svelte
Comment on lines +158 to +167
sidebarCollapsed = !nextOpen;
workspaceStore.setUi('sidebar_collapsed', sidebarCollapsed);
// Closing the sidebar also cancels any in-flight peek.
if (sidebarCollapsed === false) {
sidebarPeeking = false;
if (peekLeaveTimer !== null) {
clearTimeout(peekLeaveTimer);
peekLeaveTimer = null;
}
}

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "Closing the sidebar also cancels any in-flight peek", but the condition is if (sidebarCollapsed === false) (i.e. when re-opening). This looks like comment drift that could mislead future changes; either adjust the comment to match the behavior or flip the condition if the intent was to cancel on collapse.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/components/Sidebar.svelte
Base automatically changed from claude/adaptive-ui-phase-2-project-colors to main April 15, 2026 18:47
@bashandbone

Copy link
Copy Markdown
Contributor Author

@copilot apply changes based on the comments in this thread

… fix comment drift in toggleSidebar

Agent-Logs-Url: https://github.com/knitli/tend/sessions/9917aefd-bd3d-456c-91c6-18285b52f5d3

Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com>

Copilot AI commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

Done in commit 4682fb9. Applied all four changes from the review thread:

  1. Sidebar.svelte:197 — Added inert and style:pointer-events="none" to the <aside> when !open && !peeking, complementing the existing aria-hidden so keyboard focus cannot reach invisible sidebar items.
  2. +page.svelte:305-314 — Gated both open and peeking props with focusMode === 'none' so the sidebar's Collapsible.Content enters a fully closed/inert state while focus mode is active, removing it from the accessibility tree.
  3. +page.svelte:355-389 — Added aria-hidden={focusMode !== 'none'} and inert={focusMode !== 'none' ? true : undefined} to .session-panel so hidden session controls are removed from the tab order in focus mode.
  4. +page.svelte:158-167 — Fixed comment drift: "Closing the sidebar also cancels any in-flight peek" → "Opening the sidebar also cancels any in-flight peek" (the condition fires when sidebarCollapsed === false, i.e. on open).

pnpm check 0 errors, pnpm test 48/48 passing.

Signed-off-by: Adam Poulemanos <89049923+bashandbone@users.noreply.github.com>
@bashandbone

Copy link
Copy Markdown
Contributor Author

@copilot please address svelte-check failures in +pages.svelte

I probably did this accidentally when merging in main

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 5 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/routes/+page.svelte Outdated
Comment on lines +80 to +84
const activeSessionIds = $derived<Set<number>>(
activeSessionId !== null ? new Set([activeSessionId]) : new Set(),
);

let sessionListRef = $state<{ focusFilter: () => void } | undefined>();

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

activeSessionIds and sessionListRef are also re-declared a second time in the same <script> block. Duplicate const/let declarations will fail to compile; remove the later duplicates and use the original declarations above.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/components/ColorSwatchPicker.svelte Outdated
Comment thread src/lib/components/Sidebar.svelte Outdated
Comment thread src/lib/components/Sidebar.svelte Outdated
Comment thread src/routes/+page.svelte Outdated
Comment on lines +67 to +76
/** P1-B: monotonic token that increments on every session activation. Passed
* to SplitView so it can re-trigger the 1.5 s border flash even when the
* user clicks an already-active session row (setting the same boolean
* `highlighted=true` twice wouldn't restart the CSS animation). Phase 4
* will expand this to support one token per slot. */
let highlightToken = $state(0);
/** Session id that was most recently activated. Only the pane rendering
* this session receives a non-zero token (so flashes don't bleed across
* slots once Phase 4 lands). */
let highlightSessionId = $state<number | null>(null);

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are duplicate let/const declarations in the <script> block (highlightToken and highlightSessionId are declared twice). This will be a syntax/compile error; remove the second set of declarations and keep a single source of truth for these Phase 1 variables.

Copilot uses AI. Check for mistakes.
…main

Agent-Logs-Url: https://github.com/knitli/tend/sessions/d04643fc-bb2c-4ff7-b5e9-19fe780ed698

Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com>

Copilot AI commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

@copilot please address svelte-check failures in +pages.svelte

I probably did this accidentally when merging in main

Fixed in commit 7f86b3f. The merge from main introduced a duplicate block of four variable declarations (highlightToken, highlightSessionId, activeSessionIds, sessionListRef) at lines 67–84 that already existed at lines 35–47. Removed the duplicates. svelte-check now reports 0 errors, 48/48 tests pass.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Adam Poulemanos <89049923+bashandbone@users.noreply.github.com>
@bashandbone
Adam Poulemanos (bashandbone) merged commit 60d77de into main Apr 15, 2026
4 of 5 checks passed
@bashandbone
Adam Poulemanos (bashandbone) deleted the claude/adaptive-ui-phase-3-sidebar-focus branch April 15, 2026 19:32
@bashandbone
Adam Poulemanos (bashandbone) restored the claude/adaptive-ui-phase-3-sidebar-focus branch April 15, 2026 19:32
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants