Refactor main layout: replace tabs with left nav rail and full-page views#10
Conversation
…ects / Settings Swap the top-tab navigation (Sessions / Workspace / Overview) for a retractable left-rail nav inspired by a UI layout the user liked. Five primary sections now live on the rail; Settings is a full page instead of a modal; Projects gets a full-page view with search + sort; Sessions gains agent-kind filter chips (Claude / Copilot / Gemini / Codex) and a project dropdown. Preserved: - Focus view (Ctrl+Shift+F + Escape) - Project sidebar, retractable edge toggle - Pane workspace, slots, ghost sessions, layout switcher - Workspace-state persistence (debounced) Persistence: `workspace.ui.active_view` now takes `workspaces | dashboard | sessions | projects | settings` with one-time migration from the old `workspace` / `overview` values; new key `workspace.ui.nav_rail_collapsed` tracks rail state. Removed unused MainTabs + SettingsDialog (replaced by NavRail + SettingsPage).
There was a problem hiding this comment.
Pull request overview
This PR refactors the app’s main layout from a tabbed interface to a left navigation rail with full-page views, promoting “Sessions / Workspaces” into pane-rendering pages and moving “Dashboard / Projects / Settings” into dedicated full-page sections.
Changes:
- Replaces
MainTabswith a new collapsibleNavRailand updates+page.svelteto conditionally mount full-page views. - Introduces new full-page components for Dashboard, Projects, and Settings, plus a shared
PageHeader. - Extends
SessionListwith optional “extended filters” UI and adds agent-kind classification utilities + filter chips.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/routes/+page.svelte | Switches top-level navigation to NavRail, adds full-page view routing, and updates PTY forwarding rules for non-pane views. |
| src/lib/util/agentKind.ts | Adds session “agent kind” classification utility for filter UI. |
| src/lib/components/pages/SettingsPage.svelte | New full-page Settings view (notifications, directories, resync, about). |
| src/lib/components/pages/ProjectsPage.svelte | New full-page Projects list with search/sort, color picker, archive/unarchive, and spawn actions. |
| src/lib/components/pages/DashboardPage.svelte | Wraps CrossProjectOverview in page chrome for the Dashboard section. |
| src/lib/components/SettingsDialog.svelte | Removes the modal settings dialog (replaced by SettingsPage). |
| src/lib/components/SessionList.svelte | Adds showExtendedFilters UI (agent-kind chips + project dropdown) and callback for project scope changes. |
| src/lib/components/PageHeader.svelte | Adds reusable page header component for new full-page views. |
| src/lib/components/NavRail.svelte | Adds new primary navigation rail component with collapse toggle. |
| src/lib/components/MainTabs.test.ts | Removes tests for the old MainTabs navigation component. |
| src/lib/components/MainTabs.svelte | Removes the old tab-based navigation component. |
| src/lib/components/FilterChips.svelte | Adds agent-kind filter chip strip used by Sessions page extended filters. |
| .gitignore | Ignores .workbench/ directory. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| workbench DB managed by the Rust backend. Paths here are read-only. --> | ||
| <section class="card"> | ||
| <h2 class="card-title"> | ||
| <span class="icon" aria-hidden="true">🗂</span> Data Source | ||
| </h2> | ||
| <div class="field"> | ||
| <span class="field-label">Workbench database</span> | ||
| <code class="path">~/.local/share/tend/workbench.db</code> | ||
| </div> | ||
| <div class="field"> | ||
| <span class="field-label">Daemon socket</span> | ||
| <code class="path">~/.local/share/tend/daemon.sock</code> | ||
| </div> | ||
| <p class="field-hint"> | ||
| tend stores all state locally. These paths are managed by the | ||
| workbench — read-only. |
There was a problem hiding this comment.
The displayed “Workbench database” path is hard-coded to a Linux XDG default, but the backend resolves the DB path via dirs::data_local_dir() (platform-dependent). This will show incorrect info on non-Linux platforms and if the user’s XDG dirs differ. Consider fetching the actual resolved path from the backend (new command) or removing the concrete path and describing it generically.
| workbench DB managed by the Rust backend. Paths here are read-only. --> | |
| <section class="card"> | |
| <h2 class="card-title"> | |
| <span class="icon" aria-hidden="true">🗂</span> Data Source | |
| </h2> | |
| <div class="field"> | |
| <span class="field-label">Workbench database</span> | |
| <code class="path">~/.local/share/tend/workbench.db</code> | |
| </div> | |
| <div class="field"> | |
| <span class="field-label">Daemon socket</span> | |
| <code class="path">~/.local/share/tend/daemon.sock</code> | |
| </div> | |
| <p class="field-hint"> | |
| tend stores all state locally. These paths are managed by the | |
| workbench — read-only. | |
| workbench DB managed by the Rust backend. Locations here are read-only. --> | |
| <section class="card"> | |
| <h2 class="card-title"> | |
| <span class="icon" aria-hidden="true">🗂</span> Data Source | |
| </h2> | |
| <div class="field"> | |
| <span class="field-label">Workbench database</span> | |
| <code class="path">Managed by tend in your local app data directory</code> | |
| </div> | |
| <div class="field"> | |
| <span class="field-label">Daemon socket</span> | |
| <code class="path">Managed by tend in its local runtime/app data location</code> | |
| </div> | |
| <p class="field-hint"> | |
| tend stores all state locally. Exact locations are platform-dependent | |
| and managed by the workbench — read-only. |
| <div class="field"> | ||
| <span class="field-label">Daemon socket</span> | ||
| <code class="path">~/.local/share/tend/daemon.sock</code> | ||
| </div> | ||
| <p class="field-hint"> | ||
| tend stores all state locally. These paths are managed by the |
There was a problem hiding this comment.
The “Daemon socket” path shown here (~/.local/share/tend/daemon.sock) doesn’t match the backend’s daemon socket location (it binds $XDG_RUNTIME_DIR/tend.sock with /tmp/tend-$UID.sock fallback). This is user-visible misinformation; please either display the real resolved path (from backend) or remove the path from the UI.
| <div class="field"> | |
| <span class="field-label">Daemon socket</span> | |
| <code class="path">~/.local/share/tend/daemon.sock</code> | |
| </div> | |
| <p class="field-hint"> | |
| tend stores all state locally. These paths are managed by the | |
| <p class="field-hint"> | |
| tend stores all state locally. This path is managed by the |
| <h2 class="card-title"> | ||
| <span class="icon" aria-hidden="true">🔔</span> Notifications | ||
| </h2> | ||
| <p class="card-subtitle">Global defaults. Per-project overrides live on the project page.</p> |
There was a problem hiding this comment.
This copy claims “Per-project overrides live on the project page,” but there’s no per-project notification settings UI in this PR (and SettingsDialog removal eliminates the previous per-project surface). Please update the text to avoid implying functionality that isn’t available.
| <p class="card-subtitle">Global defaults. Per-project overrides live on the project page.</p> | |
| <p class="card-subtitle">Configure default notification preferences for tend.</p> |
| await notificationPreferenceSet({ channels, quietHours: quiet }); | ||
| saved = true; | ||
| setTimeout(() => (saved = false), 1500); | ||
| } catch (err) { |
There was a problem hiding this comment.
setTimeout(() => (saved = false), 1500) isn’t cleared on subsequent saves or when navigating away from Settings, so it can update state after unmount and create overlapping timers. Mirror the pattern used elsewhere (store timer handle, clear before setting a new one, and/or clean up in an effect/onDestroy).
| function handleKeydown(event: KeyboardEvent, id: NavId): void { | ||
| if (event.key === 'Enter' || event.key === ' ') { | ||
| event.preventDefault(); | ||
| onChange(id); | ||
| } | ||
| } |
There was a problem hiding this comment.
These nav items are real <button> elements, which already handle Enter/Space activation natively. Adding a custom onkeydown handler risks double-firing onChange (keydown + synthesized click) depending on browser behavior, and it changes the activation timing for Space. Consider removing handleKeydown / onkeydown here and relying on the button’s built-in keyboard support.
| export type NavId = 'workspaces' | 'dashboard' | 'sessions' | 'projects' | 'settings'; | ||
|
|
||
| interface Props { | ||
| value: NavId; | ||
| onChange: (next: NavId) => void; | ||
| open?: boolean; | ||
| onToggle?: (nextOpen: boolean) => void; | ||
| contentId?: string; | ||
| } |
There was a problem hiding this comment.
This PR removes MainTabs.test.ts but doesn’t add replacement tests for the new primary navigation. Given that navigation gating is important to avoid mounting terminal panes unexpectedly, please add a NavRail component test (Vitest) that covers: emitting onChange for each item, aria-current behavior, and collapse/expand toggle wiring.
| if (/\bgemini\b/.test(haystack)) return "gemini"; | ||
| if (/\bcodex\b/.test(haystack)) return "codex"; | ||
| return "other"; | ||
| } |
There was a problem hiding this comment.
The agent-kind classifier is regex/heuristic-based and will be easy to regress as new agents/commands are added. Please add unit tests covering the expected matches (e.g. claude, claude-code, gh copilot, copilot-cli) and priority ordering, including the other fallback.
| } | |
| } | |
| const vitest = (import.meta as ImportMeta & { | |
| vitest?: { | |
| describe: (name: string, fn: () => void) => void; | |
| it: (name: string, fn: () => void) => void; | |
| expect: (value: unknown) => { toBe: (expected: unknown) => void }; | |
| }; | |
| }).vitest; | |
| if (vitest) { | |
| const { describe, it, expect } = vitest; | |
| const makeSession = (command: string[] = [], label = ""): SessionSummary => | |
| ({ | |
| label, | |
| metadata: { command }, | |
| }) as SessionSummary; | |
| describe("sessionAgentKind", () => { | |
| it("matches claude", () => { | |
| expect(sessionAgentKind(makeSession(["claude"]))).toBe("claude"); | |
| }); | |
| it("matches claude-code as claude", () => { | |
| expect(sessionAgentKind(makeSession(["claude-code"]))).toBe("claude"); | |
| }); | |
| it("matches gh copilot", () => { | |
| expect(sessionAgentKind(makeSession(["gh", "copilot"]))).toBe("copilot"); | |
| }); | |
| it("matches copilot-cli", () => { | |
| expect(sessionAgentKind(makeSession(["copilot-cli"]))).toBe("copilot"); | |
| }); | |
| it("applies priority ordering when multiple agents match", () => { | |
| expect(sessionAgentKind(makeSession(["gh", "copilot", "claude-code"]))).toBe("claude"); | |
| expect(sessionAgentKind(makeSession(["gemini", "codex"]))).toBe("gemini"); | |
| }); | |
| it("falls back to other when nothing matches", () => { | |
| expect(sessionAgentKind(makeSession(["custom-tool"], "plain session"))).toBe("other"); | |
| }); | |
| }); | |
| } |
|
@copilot apply changes based on the comments in this thread |
Agent-Logs-Url: https://github.com/knitli/tend/sessions/019e5680-e651-40f2-9d12-804e1623082c Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com>
Implemented the requested thread changes in commit |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let syncing = $state(false); | ||
| async function resync(): Promise<void> { | ||
| syncing = true; | ||
| try { | ||
| await Promise.all([ | ||
| projectsStore.hydrate({ includeArchived: true }), | ||
| sessionsStore.hydrate({ includeEnded: false }), | ||
| ]); |
There was a problem hiding this comment.
resync() always clears syncing in finally, but any hydrate failure is swallowed (no user-facing error state). Since this is an explicit Settings action, consider capturing/displaying an error (e.g., syncError from caught exception and/or surfacing projectsStore.error / sessionsStore.error) so users know the re-sync did not succeed.
| let syncing = $state(false); | |
| async function resync(): Promise<void> { | |
| syncing = true; | |
| try { | |
| await Promise.all([ | |
| projectsStore.hydrate({ includeArchived: true }), | |
| sessionsStore.hydrate({ includeEnded: false }), | |
| ]); | |
| let syncing = $state(false); | |
| let syncError = $state<string | null>(null); | |
| function getSyncErrorMessage(err: unknown): string { | |
| return ( | |
| projectsStore.error || | |
| sessionsStore.error || | |
| (err instanceof Error ? err.message : String(err)) | |
| ); | |
| } | |
| async function resync(): Promise<void> { | |
| syncing = true; | |
| syncError = null; | |
| try { | |
| await Promise.all([ | |
| projectsStore.hydrate({ includeArchived: true }), | |
| sessionsStore.hydrate({ includeEnded: false }), | |
| ]); | |
| const storeError = projectsStore.error || sessionsStore.error; | |
| if (storeError) { | |
| syncError = storeError; | |
| } | |
| } catch (err) { | |
| syncError = getSyncErrorMessage(err); |
| const idx = p.canonical_path.replace(/\/$/, '').lastIndexOf('/'); | ||
| if (idx > 0) seen.add(p.canonical_path.slice(0, idx)); |
There was a problem hiding this comment.
projectParents derives the parent directory by searching for '/' separators in canonical_path. The backend stores canonical_path via PathBuf::to_string_lossy(), which will use platform path separators (e.g. \ on Windows), so this will produce incorrect parents on non-POSIX paths. Consider splitting on both / and \ (or using a small helper that finds the last occurrence of either separator) before slicing.
| const idx = p.canonical_path.replace(/\/$/, '').lastIndexOf('/'); | |
| if (idx > 0) seen.add(p.canonical_path.slice(0, idx)); | |
| const canonicalPath = p.canonical_path.replace(/[\\/]+$/, ''); | |
| const idx = Math.max( | |
| canonicalPath.lastIndexOf('/'), | |
| canonicalPath.lastIndexOf('\\'), | |
| ); | |
| if (idx > 0) seen.add(canonicalPath.slice(0, idx)); |
| <div class="field"> | ||
| <span class="field-label">Version</span> | ||
| <span>0.1.0</span> | ||
| </div> |
There was a problem hiding this comment.
The About card hard-codes the app version as 0.1.0, which will drift as soon as the package/tauri version changes. Consider sourcing this from a single canonical place (e.g. package.json at build time or @tauri-apps/api/app at runtime) to avoid stale UI.
| <div class="filter-chips" role="radiogroup" aria-label="Filter by agent"> | ||
| <button | ||
| type="button" | ||
| class="chip chip-all" | ||
| class:active={value === null} | ||
| role="radio" | ||
| aria-checked={value === null} | ||
| onclick={() => onChange(null)} | ||
| > | ||
| All | ||
| </button> | ||
| {#each AGENT_KIND_META as meta (meta.id)} | ||
| <button | ||
| type="button" | ||
| class="chip" | ||
| class:active={value === meta.id} | ||
| style="--chip-color: {meta.color}" | ||
| role="radio" | ||
| aria-checked={value === meta.id} | ||
| onclick={() => onChange(meta.id)} | ||
| > | ||
| <span class="dot" aria-hidden="true"></span> | ||
| {meta.label} | ||
| </button> |
There was a problem hiding this comment.
FilterChips uses role="radiogroup" with <button role="radio">, but without roving tabindex and arrow-key handling this doesn’t behave like a true radio group for keyboard/screen-reader users (it creates multiple tab stops and lacks expected arrow navigation). Consider either implementing proper radio-group keyboard behavior or switching to simpler semantics (e.g. plain buttons with aria-pressed for a toggle strip).
| {#each projectsStore.activeProjects as project (project.id)} | ||
| <option value={String(project.id)}>{project.display_name}</option> |
There was a problem hiding this comment.
The project dropdown options are populated from projectsStore.activeProjects, but its value is driven by selectedProjectId. If selectedProjectId ever points at an archived project (possible since Sidebar can show/select archived projects), the <select> won’t have a matching option and the UI will desync from the actual filter. Consider either listing projectsStore.projects (and disabling archived options), or coercing archived selectedProjectId to null when showExtendedFilters is enabled.
| {#each projectsStore.activeProjects as project (project.id)} | |
| <option value={String(project.id)}>{project.display_name}</option> | |
| {#each projectsStore.projects as project (project.id)} | |
| <option value={String(project.id)} disabled={project.archived}> | |
| {project.display_name} | |
| </option> |
| async function handleColorChange(project: Project, hex: string): Promise<void> { | ||
| pendingColor = { ...pendingColor, [project.id]: hex }; | ||
| await projectsStore.update(project.id, { | ||
| settings: { ...project.settings, color: hex }, | ||
| }); | ||
| } |
There was a problem hiding this comment.
ProjectsPage updates project color by awaiting projectsStore.update(...) on every onChange from ColorSwatchPicker. That picker emits on every drag tick, so this will generate a burst of backend project_update calls while the user drags. Consider debouncing (like Sidebar.svelte does) and/or only persisting on picker close, while still updating local pending color for immediate UI feedback.
|
@copilot apply changes based on the comments in this thread |
…r updates Agent-Logs-Url: https://github.com/knitli/tend/sessions/b4b41095-750f-457e-a4df-776484377219 Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com>
…sistence Agent-Logs-Url: https://github.com/knitli/tend/sessions/b4b41095-750f-457e-a4df-776484377219 Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com>
Agent-Logs-Url: https://github.com/knitli/tend/sessions/b4b41095-750f-457e-a4df-776484377219 Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com>
Agent-Logs-Url: https://github.com/knitli/tend/sessions/b4b41095-750f-457e-a4df-776484377219 Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com>
Implemented the requested thread updates across commits |
Summary
This PR restructures the main application layout from a tab-based interface to a left navigation rail with full-page views. The old
MainTabscomponent (Sessions / Workspace / Overview) is replaced with a newNavRailcomponent offering five top-level sections: Workspaces, Dashboard, Sessions, Projects, and Settings. Each section now renders as a full page rather than a tab pane.Key Changes
New Navigation Rail (
NavRail.svelte): Retractable left sidebar with five nav items (Workspaces, Dashboard, Sessions, Projects, Settings). Collapse state persisted viaworkspace.ui.nav_rail_collapsed.Full-Page Views:
DashboardPage.svelte— wraps existingCrossProjectOverviewwith page chromeProjectsPage.svelte— new full-page projects list with sort/filter controls, color picker, and archive/spawn actionsSettingsPage.svelte— replaces modalSettingsDialogwith full-page settings (notifications, data sync, development directories, about)SessionsPage.svelte— full-page sessions view with extended filters (agent-kind chips, project dropdown)New Components:
PageHeader.svelte— shared header for all full-page views (title, subtitle, trailing slot)FilterChips.svelte— agent-kind filter toggle strip for sessionsRemoved Components:
MainTabs.svelteandMainTabs.test.ts— replaced byNavRailSettingsDialog.svelte— functionality moved toSettingsPageUpdated
+page.svelte:MainTabswithNavRailand conditional full-page view renderingnavRailOpenstate (persisted viaworkspace.ui.nav_rail_collapsed)activeViewtype fromTabIdtoNavIdwith new values:'workspaces' | 'dashboard' | 'sessions' | 'projects' | 'settings'isPaneView()helper to identify views that render PTY panes (sessions/workspaces)Updated
SessionList.svelte:showExtendedFiltersprop to optionally render agent-kind filter chips and project dropdownonProjectScopeChangecallback for parent-driven project scope updatesNew Utility:
agentKind.ts— classifies sessions by CLI agent (claude, copilot, gemini, codex, other) inferred from metadata for filter UIImplementation Details
bits-ui'sCollapsibleprimitive for smooth open/close transitionsactiveViewto avoid mounting unnecessary components (e.g., PTY panes when viewing settings)ProjectsPagewith pending color trackingsetActiveView,toggleNavRail) to ensure persistence side-effects are not forgottenhttps://claude.ai/code/session_01MPgNEJSattnUisixVxNM63