Skip to content

Refactor main layout: replace tabs with left nav rail and full-page views#10

Merged
bashandbone merged 7 commits into
mainfrom
claude/borrow-ui-layout-lWaYQ
Apr 20, 2026
Merged

Refactor main layout: replace tabs with left nav rail and full-page views#10
bashandbone merged 7 commits into
mainfrom
claude/borrow-ui-layout-lWaYQ

Conversation

@bashandbone
Copy link
Copy Markdown
Contributor

Summary

This PR restructures the main application layout from a tab-based interface to a left navigation rail with full-page views. The old MainTabs component (Sessions / Workspace / Overview) is replaced with a new NavRail component 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 via workspace.ui.nav_rail_collapsed.

  • Full-Page Views:

    • DashboardPage.svelte — wraps existing CrossProjectOverview with page chrome
    • ProjectsPage.svelte — new full-page projects list with sort/filter controls, color picker, and archive/spawn actions
    • SettingsPage.svelte — replaces modal SettingsDialog with 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 sessions
  • Removed Components:

    • MainTabs.svelte and MainTabs.test.ts — replaced by NavRail
    • SettingsDialog.svelte — functionality moved to SettingsPage
  • Updated +page.svelte:

    • Replaced MainTabs with NavRail and conditional full-page view rendering
    • Added navRailOpen state (persisted via workspace.ui.nav_rail_collapsed)
    • Updated activeView type from TabId to NavId with new values: 'workspaces' | 'dashboard' | 'sessions' | 'projects' | 'settings'
    • Added isPaneView() helper to identify views that render PTY panes (sessions/workspaces)
    • PTY forwarding now silences for all non-pane views (dashboard, projects, settings)
  • Updated SessionList.svelte:

    • Added showExtendedFilters prop to optionally render agent-kind filter chips and project dropdown
    • Added onProjectScopeChange callback for parent-driven project scope updates
  • New Utility: agentKind.ts — classifies sessions by CLI agent (claude, copilot, gemini, codex, other) inferred from metadata for filter UI

Implementation Details

  • The nav rail uses bits-ui's Collapsible primitive for smooth open/close transitions
  • Full-page views are conditionally rendered based on activeView to avoid mounting unnecessary components (e.g., PTY panes when viewing settings)
  • Project color picker state is managed per-row in ProjectsPage with pending color tracking
  • Settings page includes data sync functionality that re-hydrates both projects and sessions stores
  • All navigation state changes route through setter functions (setActiveView, toggleNavRail) to ensure persistence side-effects are not forgotten

https://claude.ai/code/session_01MPgNEJSattnUisixVxNM63

claude and others added 2 commits April 19, 2026 19:37
…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).
Copilot AI review requested due to automatic review settings April 19, 2026 20:39
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 MainTabs with a new collapsible NavRail and updates +page.svelte to conditionally mount full-page views.
  • Introduces new full-page components for Dashboard, Projects, and Settings, plus a shared PageHeader.
  • Extends SessionList with 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.

Comment on lines +104 to +119
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.
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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.

Copilot uses AI. Check for mistakes.
Comment on lines +113 to +118
<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
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
<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

Copilot uses AI. Check for mistakes.
<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>
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
<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>

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +67
await notificationPreferenceSet({ channels, quietHours: quiet });
saved = true;
setTimeout(() => (saved = false), 1500);
} catch (err) {
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment thread src/lib/components/NavRail.svelte Outdated
Comment on lines +71 to +76
function handleKeydown(event: KeyboardEvent, id: NavId): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onChange(id);
}
}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +22
export type NavId = 'workspaces' | 'dashboard' | 'sessions' | 'projects' | 'settings';

interface Props {
value: NavId;
onChange: (next: NavId) => void;
open?: boolean;
onToggle?: (nextOpen: boolean) => void;
contentId?: string;
}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/util/agentKind.ts
if (/\bgemini\b/.test(haystack)) return "gemini";
if (/\bcodex\b/.test(haystack)) return "codex";
return "other";
}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
}
}
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 uses AI. Check for mistakes.
@bashandbone
Copy link
Copy Markdown
Contributor Author

@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>
Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 20, 2026

@copilot apply changes based on the comments in this thread

Implemented the requested thread changes in commit 3e5efd5: fixed Settings copy/path messaging, added save-timer cleanup, removed NavRail keydown double-activation risk, and added NavRail + agentKind tests. Screenshot: https://github.com/user-attachments/assets/b626d52a-5d1f-4fe6-8ee7-0d57d2820102

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 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.

Comment on lines +93 to +100
let syncing = $state(false);
async function resync(): Promise<void> {
syncing = true;
try {
await Promise.all([
projectsStore.hydrate({ includeArchived: true }),
sessionsStore.hydrate({ includeEnded: false }),
]);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +110 to +111
const idx = p.canonical_path.replace(/\/$/, '').lastIndexOf('/');
if (idx > 0) seen.add(p.canonical_path.slice(0, idx));
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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));

Copilot uses AI. Check for mistakes.
Comment on lines +232 to +235
<div class="field">
<span class="field-label">Version</span>
<span>0.1.0</span>
</div>
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/components/FilterChips.svelte Outdated
Comment on lines +19 to +42
<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>
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment thread src/lib/components/SessionList.svelte Outdated
Comment on lines +303 to +304
{#each projectsStore.activeProjects as project (project.id)}
<option value={String(project.id)}>{project.display_name}</option>
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
{#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>

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +90
async function handleColorChange(project: Project, hex: string): Promise<void> {
pendingColor = { ...pendingColor, [project.id]: hex };
await projectsStore.update(project.id, {
settings: { ...project.settings, color: hex },
});
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

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 uses AI. Check for mistakes.
@bashandbone
Copy link
Copy Markdown
Contributor Author

@copilot apply changes based on the comments in this thread

Copilot AI and others added 4 commits April 20, 2026 01:30
…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>
Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 20, 2026

@copilot apply changes based on the comments in this thread

Implemented the requested thread updates across commits 155e9da, 70056bc, and 5ceb39c (latest): Settings resync error surfacing + cross-platform path parent logic + dynamic version, FilterChips accessibility semantics, SessionList archived-project dropdown handling, and debounced Projects color persistence with flush/error handling. Screenshot: https://github.com/user-attachments/assets/19500fd2-fa98-4c76-b8b6-c5e9976352d4

@bashandbone bashandbone merged commit 10a4e0b into main Apr 20, 2026
7 checks passed
@bashandbone bashandbone deleted the claude/borrow-ui-layout-lWaYQ branch April 20, 2026 02:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants