UI shell & theme
Layout (App.tsx)
Three resizable regions, left to right — chat is the primary, central surface; everything else (file tree, file editing, terminals, sub-agent monitoring) lives in a multi-tab panel on the right:
+---------+---------------------------------+------------------------+
| ai leash| CenterPanel tab strip | SidePanel tab strip |
| [+] | (Agent, always-open + one | (Files / Terminal 1 / |
|---------| tab per opened sub-agent) | Sub Agents / ...) + |
| project | | active tab's content |
| list | active tab's content | |
+---------+---------------------------------+------------------------+
| StatusBar |
+---------------------------------------------------------------------+LeftBar.tsxis the project switcher: a header row (Logo— an inline SVG rendering “ai” with alinearGradientfill and “leash” in plain text, plus a+button that opens the native folder-picker dialog and callsstore.openProject) above a list ofstore.ts’srecentProjects. Clicking a project row callsopenProject(path)to switch to it (highlighted when it matchesprojectRoot). One row = one project = one conversation for now; wiring multiple named conversations per project is future work.recentProjects: {path, name, lastMessageAt}[]persists tolocalStorage(ai-leash:recentProjects), deduped by path — but displayed sorted bylastMessageAtdescending (computed at render time inLeftBar, the stored array order doesn’t matter). Merely opening/switching to a project (openProject) does not touchlastMessageAtor reorder anything; onlytouchProjectActivitydoes, called when a project’schat://{path}/generatingevent firestrue(see “generating” in agent-chat.md below) — i.e. an actual chat turn starting, not just looking at a project. This was a deliberate fix: reordering on every switch made the sidebar reshuffle under you as you clicked around to look at things. A one-time migration inloadRecentProjects()seeds this list from the old single-projectai-leash:lastProjectRootkey (from before this sidebar existed) and removes that key.restoreLastProject(called once on app mount) opens whichever project has the highestlastMessageAt(0 if a project’s never had one, i.e. never chatted in) rather than a dedicated last-project key; if that path fails to open (e.g. deleted/moved), it’s dropped from the list. Each row (ProjectRow) shows a small pulsing blue dot next to the name whenstore.generatingSessions[path]is true — sinceLeftBaris always mounted, this reflects a project generating in the background even while you’re looking at a different one (see “generating” in agent-chat.md for where that state comes from).CenterPanel.tsxis the main, central column (no fixed width, no longer lives in a right-hand sidebar) and is itself a small tab strip: a permanent, non-closableAgenttab (ChatPanel, kept mounted — hidden via CSS, not unmounted — whenever another center tab is active, so its session/sessionIdand in-flight streaming survive switching away) plus one closable tab per opened sub-agent conversation (SubAgentChatTab.tsx, read-only: no input box, no retry — a sub-agent can’t be messaged further once spawned).store.ts’schatTabs: ChatTab[]+activeChatTabIdtrack these;subAgentThreads: Record<subSessionId, Entry[]>holds each sub-agent’s flat transcript, mirrored in parallel with the existing nested-under-tool-call thread byChatPanel’ssubtask_starthandler (both read from the same shared helpers insrc/lib/chatEntries.ts—Entry,appendThinking,appendChunk,appendToolCall,applyToolResult— extracted fromChatPanel.tsxso both consumers stay in sync off one implementation). Two ways to open a sub-agent’s tab:- Clicking its row in the right
SidePanel’sSub Agentstab callsopenChatTab(subSessionId, description)(dedupes bysubagent:${subSessionId}, just activates if already open). - The instant a sub-agent spawns,
ChatPanel’ssubtask_starthandler callsopenPanelTab("subagents")to surface the right sidebar’sSub Agentstab automatically — not the center tab itself, which still requires a manual click, only the place you’d notice it running. Switching projects (openProject) resetschatTabsback to justAgent— a stale sub-agent tab from a different project’s conversation showing up in the center pane would be the wrong context.subAgentTasks/subAgentThreadsdeliberately do not reset on project switch, though — the Sub Agents sidebar list is a cross-project history, not per-conversation state, so it doesn’t empty out every time you switch away and back (a past bug). It’s also no longer purely in-memory:store.loadSubAgentTasks()fetches every persisted sub-agent from SQLite (list_sub_agents, see conversation-history.md) and merges in anything not already known locally (bysubSessionId, so it can’t clobber a live update), called once onAppmount and again every timeSubAgentsTabmounts. There’s no age cap anymore — sub-agents are kept indefinitely.SubAgentChatTab.tsxdoes the equivalent for an individual transcript: ifsubAgentThreads[subSessionId]isundefined(never loaded — as opposed to[], loaded but genuinely empty) it callsload_conversation_history(subSessionId)and converts the result with the samemessagesToEntries()a top-level session’s history hydration already uses.
- Clicking its row in the right
SidePanel.tsx(right) is a genuine multi-tab panel, not a fixed set of two tabs:store.ts’spanelTabs: PanelTab[]+activePanelTabIdtrack an arbitrary number of simultaneously open tabs of kindfiletree|subagents|terminal|file.filetreeandsubagentsare singletons — opening one twice just activates the existing tab (id equals the kind itself).terminaltabs are never deduped — each open creates a newTerminalPanelinstance (own pty), idterminal:${crypto.randomUUID()}, labeledTerminal N.filetabs are deduped by path (idfile:${path}) and driven by the existingopenFiles/activePathstate —store.openFileboth loads the file’s content (if not already loaded) and callsopenPanelTab("file", ...)to add/focus its tab. Closing a file tab (closePanelTab) also drops it fromopenFiles.- When
panelTabsis empty, or the tab strip’s+button is clicked,SidePanelshowsTabPicker.tsx— a grid of tiles (one per non-file kind) instead of tab content. Picking a tile callsopenPanelTaband hides the picker. Git-diff and an embedded browser are planned additions to this same grid in milestone 8. - All currently-open
terminaltabs, and the (singleton)filetreetab if open, stay mounted (hidden via CSS, not unmounted) regardless of which tab is active — so pty sessions/scrollback and the file tree’s expanded-folder state (localuseStateinFileTree.tsx’sNode) survive switching to another tab.subagentsandfilecontent is cheap to rebuild from store state on every activation, so those unmount when inactive. FileEditorTab.tsx(replacing the oldEditorArea.tsx) renders just the CodeMirror view for the currently active file — no internal per-file tab strip of its own, since the outerSidePaneltab strip already covers that.- Tabs are remembered per project (
store.ts’spanelStateByConversation: Record<string, {panelTabs, activePanelTabId}>, keyed by project path — doubling as a conversation id for now, since it’s one conversation per project until multiple named conversations per project are wired up).openProjectsnapshots the outgoing project’spanelTabs/activePanelTabIdinto this map before switching, then restores the incoming project’s saved tabs (or starts empty, if it’s never been opened this session). This is in-memory only — not persisted tolocalStorage— so it resets on app restart. Restoring doesn’t resurrect the actual pty/component instances (those were already unmounted, killing their ptys, when you switched away) —filetabs get their content re-read from disk fresh (a since-deleted file just drops its tab silently), andterminaltabs reopen as brand-new shells under their old tab label/id.
LeftBarandSidePanelwidths are drag-resizable viaResizeHandle.tsx+ theuseResizableWidthhook (src/hooks/useResizableWidth.ts), which persists each width tolocalStorage(ai-leash:leftBarWidth,ai-leash:rightPanelWidth). Chat fills whatever space remains between them.
Permission requests are per-project, not a single app-wide overlay:
PermissionPopover.tsx renders inside ChatPanel.tsx, anchored above
that project’s own textarea, only when permissionForSession (store.ts)
resolves a pending request for the currently open session (or a sub-agent
it spawned). LeftBar.tsx is where the one global permission://request/
permission://resolved listener pair lives (mounted regardless of which
project is open, same as its existing generating listeners) — a project
awaiting approval in the background gets a pulsing amber glow on its
sidebar row (ProjectRow’s awaitingApproval) rather than being silently
invisible until you happen to switch to it. See
tools.md.
Dark mode only
There is exactly one theme; no light mode, no toggle, no
prefers-color-scheme handling. This is enforced in a few places at
once rather than relying on any single mechanism:
index.html:<html class="dark" style="color-scheme: dark">plus a<meta name="color-scheme" content="dark">tag.index.css::root { color-scheme: dark; }and a small set of CSS variables (--al-bg,--al-bg-raised,--al-bg-sunken,--al-border,--al-text,--al-text-dim,--al-accent) — though in practice most components use raw Tailwind arbitrary-value classes (e.g.bg-[#0b0c0e],border-[#26272c]) rather than these variables consistently.tauri.conf.json: the native window itself is configured with"theme": "Dark", so the OS-level window chrome (title bar, etc.) matches too, not just the web content.- Tailwind v4 is wired in via the
@tailwindcss/viteplugin (vite.config.ts) rather than atailwind.config+ PostCSS setup.
Because no component ever applies a dark: variant or a light palette,
“dark mode only” isn’t really a runtime mode switch — it’s just the only
palette that exists in the codebase.
Status bar
StatusBar.tsx: aggregate connectivity across every configured provider
(Ollama + each saved OpenAI-compatible config), as “N/M providers
connected” with a colored dot — gray until at least one check has
returned, green if all are connected, amber if some are, red if none are.
Hovering the dot/text shows a per-provider tooltip (label + “checking…” /
“connected” / “disconnected”). Backed by store.ts’s
providerConnectivity/refreshProviderConnectivity (see
agent-chat.md for the polling behavior and how this
differs from the active-provider-only ollamaConnected check).
Window
From tauri.conf.json: product name “AI Leash”, identifier
dev.patheticgeek.aileash, single window titled “AI Leash”, default size
1400×900, minimum size 900×600.