From db69dcc87eff5791ef482e3d04a729c88b7d681a Mon Sep 17 00:00:00 2001 From: Jan Karres Date: Tue, 18 Aug 2026 23:45:33 +0200 Subject: [PATCH 1/7] feat(settings): add a startup settings group --- src/lib/settings.ts | 88 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 49a8566..191c7cc 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -28,11 +28,39 @@ export type TerminalSettings = { letterSpacing: number; }; +export type StartupConnectMode = "off" | "last-host" | "pinned-host"; + +export type StartupTarget = + | "home" + | "new-chat" + | "last-chat" + | "terminal" + | "git-client" + | "system-monitor" + | "ports"; + +/** + * What the app does on a cold start. One global rule rather than one per host: + * the phone is normally pointed at a single machine, and a per-host rule would + * multiply the settings surface for no gain. + */ +export type StartupSettings = { + connect: StartupConnectMode; + /** Host id used when `connect` is "pinned-host"; ignored otherwise. */ + hostId: string; + target: StartupTarget; + /** Agent id for the two chat targets; ignored otherwise. */ + agentId: string; + /** Absolute workspace path for the chat and git targets; ignored otherwise. */ + projectPath: string; +}; + export type AppSettings = { theme: string; server: ServerSettings; editor: EditorSettings; terminal: TerminalSettings; + startup: StartupSettings; showHiddenFiles: boolean; hapticFeedback: boolean; }; @@ -103,11 +131,25 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = { letterSpacing: 1, }; +/** + * Off, and Home. That is not caution, it is the contract: the feature is + * opt-in, and an installation that never opens the Startup page keeps behaving + * exactly as the app did before it existed. + */ +export const DEFAULT_STARTUP_SETTINGS: StartupSettings = { + connect: "off", + hostId: "", + target: "home", + agentId: "", + projectPath: "", +}; + const DEFAULT_SETTINGS: AppSettings = { theme: "dark", server: DEFAULT_SERVER_SETTINGS, editor: DEFAULT_EDITOR_SETTINGS, terminal: DEFAULT_TERMINAL_SETTINGS, + startup: DEFAULT_STARTUP_SETTINGS, showHiddenFiles: false, hapticFeedback: true, }; @@ -209,6 +251,47 @@ function normalizeTerminalSettings( }; } +function isStartupConnectMode(value: unknown): value is StartupConnectMode { + return value === "off" || value === "last-host" || value === "pinned-host"; +} + +function isStartupTarget(value: unknown): value is StartupTarget { + return ( + value === "home" || + value === "new-chat" || + value === "last-chat" || + value === "terminal" || + value === "git-client" || + value === "system-monitor" || + value === "ports" + ); +} + +function normalizeStartupSettings( + settings?: Partial | null, +): StartupSettings { + return { + connect: isStartupConnectMode(settings?.connect) + ? settings.connect + : DEFAULT_STARTUP_SETTINGS.connect, + hostId: + typeof settings?.hostId === "string" + ? settings.hostId + : DEFAULT_STARTUP_SETTINGS.hostId, + target: isStartupTarget(settings?.target) + ? settings.target + : DEFAULT_STARTUP_SETTINGS.target, + agentId: + typeof settings?.agentId === "string" + ? settings.agentId + : DEFAULT_STARTUP_SETTINGS.agentId, + projectPath: + typeof settings?.projectPath === "string" + ? settings.projectPath + : DEFAULT_STARTUP_SETTINGS.projectPath, + }; +} + function normalizeSettings( settings: Partial | null | undefined, ): AppSettings { @@ -217,6 +300,7 @@ function normalizeSettings( server: normalizeServerSettings(settings?.server), editor: normalizeEditorSettings(settings?.editor), terminal: normalizeTerminalSettings(settings?.terminal), + startup: normalizeStartupSettings(settings?.startup), showHiddenFiles: typeof settings?.showHiddenFiles === "boolean" ? settings.showHiddenFiles @@ -264,6 +348,10 @@ export async function saveSettings( ...current.terminal, ...settings.terminal, }, + startup: { + ...current.startup, + ...settings.startup, + }, }); await file.write(SETTINGS_PATH, JSON.stringify(next)); window.dispatchEvent( From 0bcb23e54e4db59e488b7b58c4f31e7b85832e2e Mon Sep 17 00:00:00 2001 From: Jan Karres Date: Tue, 18 Aug 2026 23:46:44 +0200 Subject: [PATCH 2/7] feat(startup): add the pure startup planner --- src/lib/startupPlan.test.ts | 261 ++++++++++++++++++++++++++++++++++++ src/lib/startupPlan.ts | 190 ++++++++++++++++++++++++++ 2 files changed, 451 insertions(+) create mode 100644 src/lib/startupPlan.test.ts create mode 100644 src/lib/startupPlan.ts diff --git a/src/lib/startupPlan.test.ts b/src/lib/startupPlan.test.ts new file mode 100644 index 0000000..b641884 --- /dev/null +++ b/src/lib/startupPlan.test.ts @@ -0,0 +1,261 @@ +import type { AiSession } from "@shellular/protocol"; +import type { SavedHost } from "lib/machines"; +import type { StartupSettings } from "lib/settings"; +import type { AcpAgentInfo } from "state/acp"; +import type { Project } from "state/projects"; +import { describe, expect, it } from "vitest"; +import { + describeStartupTarget, + pickResumableSession, + planStartupConnect, + planStartupOpen, +} from "./startupPlan"; + +function host(hostId: string, lastConnected: number): SavedHost { + return { + hostId, + encryptionKey: "key", + hostname: hostId, + platform: "linux", + lastConnected, + }; +} + +function agent(id: string, available: boolean): AcpAgentInfo { + return { + id, + name: id, + title: id === "claude" ? "Claude Code" : id, + available, + state: available ? "ready" : "unavailable", + }; +} + +function project(path: string): Project { + return { + path, + name: path.split("/").filter(Boolean).slice(-1)[0] ?? path, + addedAt: 0, + }; +} + +function settings(overrides: Partial = {}): StartupSettings { + return { + connect: "last-host", + hostId: "", + target: "home", + agentId: "", + projectPath: "", + ...overrides, + }; +} + +function session( + id: string | undefined, + times: { createdAt: number; updatedAt?: number }, +): AiSession { + return { + id, + createdAt: times.createdAt, + updatedAt: times.updatedAt ?? times.createdAt, + } as AiSession; +} + +describe("startup connect plan", () => { + it("does nothing when auto-connect is off", () => { + expect( + planStartupConnect(settings({ connect: "off" }), [host("a", 2)]), + ).toEqual({ kind: "none" }); + }); + + it("takes the most recently connected host for the last-host mode", () => { + const hosts = [host("recent", 20), host("older", 10)]; + expect( + planStartupConnect(settings({ connect: "last-host" }), hosts), + ).toEqual({ kind: "connect", host: hosts[0] }); + }); + + it("explains itself when the last-host mode has no host to use", () => { + expect(planStartupConnect(settings({ connect: "last-host" }), [])).toEqual({ + kind: "none", + reason: "No saved host to connect to", + }); + }); + + it("resolves a pinned host by id", () => { + const hosts = [host("a", 20), host("b", 10)]; + expect( + planStartupConnect( + settings({ connect: "pinned-host", hostId: "b" }), + hosts, + ), + ).toEqual({ kind: "connect", host: hosts[1] }); + }); + + it("explains itself when the pinned host is no longer saved", () => { + expect( + planStartupConnect(settings({ connect: "pinned-host", hostId: "gone" }), [ + host("a", 20), + ]), + ).toEqual({ + kind: "none", + reason: "The host set for startup is no longer saved", + }); + }); +}); + +describe("startup open plan", () => { + const context = { + agents: { claude: agent("claude", true), broken: agent("broken", false) }, + projects: [project("/home/jk/owly-agent")], + }; + + it("opens nothing while auto-connect is off, whatever the target says", () => { + expect( + planStartupOpen( + settings({ + connect: "off", + target: "new-chat", + agentId: "claude", + projectPath: "/home/jk/owly-agent", + }), + context, + ), + ).toEqual({ kind: "none" }); + }); + + it("opens nothing for the home target", () => { + expect(planStartupOpen(settings({ target: "home" }), context)).toEqual({ + kind: "none", + }); + }); + + it("passes the parameterless targets straight through", () => { + expect(planStartupOpen(settings({ target: "terminal" }), context)).toEqual({ + kind: "terminal", + }); + expect(planStartupOpen(settings({ target: "ports" }), context)).toEqual({ + kind: "ports", + }); + expect( + planStartupOpen(settings({ target: "system-monitor" }), context), + ).toEqual({ kind: "system-monitor" }); + }); + + it("resolves a chat target to its agent and project", () => { + expect( + planStartupOpen( + settings({ + target: "new-chat", + agentId: "claude", + projectPath: "/home/jk/owly-agent", + }), + context, + ), + ).toEqual({ + kind: "new-chat", + agent: context.agents.claude, + project: context.projects[0], + }); + }); + + it("reports an agent that is not installed on this host", () => { + expect( + planStartupOpen( + settings({ + target: "last-chat", + agentId: "codex", + projectPath: "/home/jk/owly-agent", + }), + context, + ), + ).toEqual({ + kind: "unavailable", + reason: "codex is not installed on this host", + }); + }); + + it("reports an agent that is installed but unavailable", () => { + expect( + planStartupOpen( + settings({ + target: "new-chat", + agentId: "broken", + projectPath: "/home/jk/owly-agent", + }), + context, + ), + ).toEqual({ + kind: "unavailable", + reason: "broken is not available on this host", + }); + }); + + it("reports a project that is no longer on the host", () => { + expect( + planStartupOpen( + settings({ target: "git-client", projectPath: "/home/jk/gone" }), + context, + ), + ).toEqual({ + kind: "unavailable", + reason: "/home/jk/gone is not a project on this host", + }); + }); + + it("reports a target that was never finished being configured", () => { + expect( + planStartupOpen(settings({ target: "git-client" }), context), + ).toEqual({ kind: "unavailable", reason: "No project is set for startup" }); + }); +}); + +describe("resumable session", () => { + it("returns null for an empty list", () => { + expect(pickResumableSession([])).toBeNull(); + }); + + it("takes the newest session regardless of the order the host sent", () => { + const newest = session("new", { createdAt: 1, updatedAt: 30 }); + expect( + pickResumableSession([ + session("old", { createdAt: 1, updatedAt: 10 }), + newest, + session("mid", { createdAt: 1, updatedAt: 20 }), + ]), + ).toEqual(newest); + }); + + it("skips sessions without an id", () => { + const usable = session("usable", { createdAt: 1, updatedAt: 5 }); + expect( + pickResumableSession([ + session(undefined, { createdAt: 1, updatedAt: 99 }), + usable, + ]), + ).toEqual(usable); + }); + + it("falls back to createdAt when updatedAt is missing", () => { + const newest = { id: "new", createdAt: 40 } as AiSession; + expect( + pickResumableSession([{ id: "old", createdAt: 10 } as AiSession, newest]), + ).toEqual(newest); + }); +}); + +describe("startup target description", () => { + it("names the folder for the targets that carry one", () => { + expect( + describeStartupTarget( + settings({ target: "new-chat", projectPath: "/home/jk/owly-agent" }), + ), + ).toEqual("New chat · owly-agent"); + }); + + it("names the target alone for the ones that carry nothing", () => { + expect(describeStartupTarget(settings({ target: "ports" }))).toEqual( + "Ports", + ); + }); +}); diff --git a/src/lib/startupPlan.ts b/src/lib/startupPlan.ts new file mode 100644 index 0000000..12c16d2 --- /dev/null +++ b/src/lib/startupPlan.ts @@ -0,0 +1,190 @@ +import type { AiSession } from "@shellular/protocol"; +import type { SavedHost } from "lib/machines"; +import type { + StartupConnectMode, + StartupSettings, + StartupTarget, +} from "lib/settings"; +import type { AcpAgentInfo } from "state/acp"; +import type { Project } from "state/projects"; + +// ─── Types ──────────────────────────────────────────────────── + +/** What the runner should do about the connection before anything opens. */ +export type StartupConnectPlan = + | { kind: "none"; reason?: string } + | { kind: "connect"; host: SavedHost }; + +/** + * What the runner should open once the host is up. `unavailable` carries the + * sentence the toast shows; there is deliberately no fallback member, because + * silently opening something else is worse than doing nothing. + */ +export type StartupOpenPlan = + | { kind: "none" } + | { kind: "unavailable"; reason: string } + | { kind: "terminal" } + | { kind: "system-monitor" } + | { kind: "ports" } + | { kind: "git-client"; project: Project } + | { kind: "new-chat"; agent: AcpAgentInfo; project: Project } + | { kind: "last-chat"; agent: AcpAgentInfo; project: Project }; + +export interface StartupOpenContext { + agents: Record; + projects: Project[]; +} + +// ─── Options and labels ─────────────────────────────────────── + +export const STARTUP_CONNECT_OPTIONS: { + value: StartupConnectMode; + label: string; +}[] = [ + { value: "off", label: "Off" }, + { value: "last-host", label: "Last used host" }, + { value: "pinned-host", label: "Specific host" }, +]; + +export const STARTUP_TARGET_OPTIONS: { + value: StartupTarget; + label: string; +}[] = [ + { value: "home", label: "Home" }, + { value: "new-chat", label: "New chat" }, + { value: "last-chat", label: "Continue last chat" }, + { value: "terminal", label: "Terminal" }, + { value: "git-client", label: "Git client" }, + { value: "system-monitor", label: "System monitor" }, + { value: "ports", label: "Ports" }, +]; + +export function startupTargetNeedsAgent(target: StartupTarget): boolean { + return target === "new-chat" || target === "last-chat"; +} + +export function startupTargetNeedsProject(target: StartupTarget): boolean { + return ( + target === "new-chat" || target === "last-chat" || target === "git-client" + ); +} + +/** One line for the banner, built from the settings alone. */ +export function describeStartupTarget(settings: StartupSettings): string { + const label = + STARTUP_TARGET_OPTIONS.find((option) => option.value === settings.target) + ?.label ?? settings.target; + if (!startupTargetNeedsProject(settings.target)) return label; + const folder = basename(settings.projectPath); + return folder ? `${label} · ${folder}` : label; +} + +// ─── Planning ───────────────────────────────────────────────── + +export function planStartupConnect( + settings: StartupSettings, + hosts: SavedHost[], +): StartupConnectPlan { + if (settings.connect === "off") return { kind: "none" }; + + if (settings.connect === "last-host") { + // getSavedHosts() sorts by lastConnected descending, so the first entry is + // the most recent one. + const host = hosts[0]; + return host + ? { kind: "connect", host } + : { kind: "none", reason: "No saved host to connect to" }; + } + + const host = hosts.find((entry) => entry.hostId === settings.hostId); + return host + ? { kind: "connect", host } + : { + kind: "none", + reason: "The host set for startup is no longer saved", + }; +} + +export function planStartupOpen( + settings: StartupSettings, + context: StartupOpenContext, +): StartupOpenPlan { + // Every target except Home needs a live connection, and a cold start is only + // connected when auto-connect brought the host up. With auto-connect off + // there is nothing for a target to act on, so the rule stands down entirely. + if (settings.connect === "off") return { kind: "none" }; + + const { target } = settings; + if (target === "home") return { kind: "none" }; + if (target === "terminal") return { kind: "terminal" }; + if (target === "system-monitor") return { kind: "system-monitor" }; + if (target === "ports") return { kind: "ports" }; + + if (target === "git-client") { + const project = findProject(settings, context); + return project ? { kind: "git-client", project } : missingProject(settings); + } + + const agent = context.agents[settings.agentId]; + if (!agent) { + return { + kind: "unavailable", + reason: settings.agentId + ? `${settings.agentId} is not installed on this host` + : "No agent is set for startup", + }; + } + if (!agent.available) { + return { + kind: "unavailable", + reason: `${agent.title || agent.name} is not available on this host`, + }; + } + + const project = findProject(settings, context); + if (!project) return missingProject(settings); + + return { kind: target, agent, project }; +} + +/** + * The newest session of the list, by our own reckoning. The sessions page + * renders whatever order the host sends and only groups by date, so nothing in + * the app guarantees an ordering. For a list a wrong order is cosmetic; for + * "open the newest chat automatically" it would open the wrong conversation. + */ +export function pickResumableSession(sessions: AiSession[]): AiSession | null { + const usable = sessions.filter((session) => Boolean(session.id)); + if (!usable.length) return null; + return [...usable].sort( + (left, right) => sessionTime(right) - sessionTime(left), + )[0]; +} + +// ─── Helpers ────────────────────────────────────────────────── + +function findProject( + settings: StartupSettings, + context: StartupOpenContext, +): Project | undefined { + return context.projects.find( + (project) => project.path === settings.projectPath, + ); +} + +function missingProject(settings: StartupSettings): StartupOpenPlan { + return { + kind: "unavailable", + reason: settings.projectPath + ? `${settings.projectPath} is not a project on this host` + : "No project is set for startup", + }; +} + +function sessionTime(session: AiSession): number { + return session.updatedAt ?? session.createdAt ?? 0; +} + +function basename(path: string): string { + return path.split("/").filter(Boolean).slice(-1)[0] ?? ""; +} From e79b32dd548f8735dd2d98447bc9d3023597c72f Mon Sep 17 00:00:00 2001 From: Jan Karres Date: Tue, 18 Aug 2026 23:48:22 +0200 Subject: [PATCH 3/7] refactor(navigation): move the shared page pushes into lib/navigate --- src/lib/navigate.tsx | 95 +++++++++++++++++++++++++++++++ src/pages/chat/ChatSidebar.tsx | 31 ++++------ src/pages/sessions/index.tsx | 31 ++++------ src/tabs/home/ConnectionInfo.tsx | 11 +--- src/tabs/home/index.tsx | 27 +++------ src/tabs/more/index.tsx | 4 +- src/tabs/projects/ProjectList.tsx | 35 ++++-------- 7 files changed, 139 insertions(+), 95 deletions(-) create mode 100644 src/lib/navigate.tsx diff --git a/src/lib/navigate.tsx b/src/lib/navigate.tsx new file mode 100644 index 0000000..bb7d65d --- /dev/null +++ b/src/lib/navigate.tsx @@ -0,0 +1,95 @@ +import { pushPage, toToTab } from "App"; +import type { AiBackend } from "@shellular/protocol"; +import { chatTabId } from "lib/chatTabId"; +import type { AcpAgentInfo } from "state/acp"; + +/** + * Opening a page is not private to the component whose button does it: the + * startup rule opens the same pages, and the chat push in particular carries + * eleven props that have to stay in sync. These helpers are the one copy. + */ + +export interface OpenChatOptions { + agentId: AiBackend; + /** The live agent record, when the caller has one. */ + agent?: AcpAgentInfo; + /** ACP session id, or "" for a chat whose session is created on first send. */ + sessionId: string; + title: string; + workspacePath: string; + /** Explicit page-stack id, for callers that key their own chat tabs. */ + tabId?: string; + /** Overrides `agent.available`; the sessions list tracks its own value. */ + agentAvailable?: boolean; + createOnFirstMessage?: boolean; +} + +export async function openChatPage({ + agentId, + agent, + sessionId, + title, + workspacePath, + tabId, + agentAvailable, + createOnFirstMessage, +}: OpenChatOptions): Promise { + const id = tabId ?? chatTabId(agentId, sessionId); + const assistantName = agent?.name ?? agentId; + const ChatConversationPage = await import("pages/chat"); + pushPage( + id, + , + ); +} + +export async function openSessionsPage( + backend: AiBackend, + agent: AcpAgentInfo, +): Promise { + const ChatSessionsPage = await import("pages/sessions"); + pushPage( + `ai-${backend}`, + , + ); +} + +export async function openGitClientPage( + projectPath: string, + projectName: string, +): Promise { + const GitClientPage = await import("pages/git-client"); + pushPage( + `git-client-${projectPath}`, + , + ); +} + +export async function openSystemMonitorPage(): Promise { + const SysmonPage = await import("pages/sysmon"); + pushPage("system-monitor", ); +} + +export async function openPortsPage(): Promise { + const PortsPage = await import("pages/ports"); + pushPage("ports", , { showConnectionBanner: false }); +} + +export function openTerminalTab(): void { + toToTab("terminals"); +} diff --git a/src/pages/chat/ChatSidebar.tsx b/src/pages/chat/ChatSidebar.tsx index 1b01fae..9f9089d 100644 --- a/src/pages/chat/ChatSidebar.tsx +++ b/src/pages/chat/ChatSidebar.tsx @@ -1,7 +1,7 @@ -import { pushPage } from "App"; import type { AiBackend } from "@shellular/protocol"; import AgentIcon from "components/AgentIcon"; import { getAgentIcon } from "lib/agents"; +import { openChatPage } from "lib/navigate"; import { useEffect, useState } from "react"; import { useShellular } from "state"; import type { AcpAgentInfo } from "state/acp"; @@ -260,24 +260,13 @@ function pushChat({ workspacePath: string; agent: AcpAgentInfo | undefined; }) { - const agentName = agent?.name ?? agentId; - import("pages/chat").then((mod) => { - const ChatConversationPage = mod.default; - pushPage( - tabId, - , - ); - }); + openChatPage({ + agentId, + agent, + sessionId, + title, + workspacePath, + tabId, + createOnFirstMessage: !sessionId, + }).catch(console.error); } diff --git a/src/pages/sessions/index.tsx b/src/pages/sessions/index.tsx index ee28878..f09d2bb 100644 --- a/src/pages/sessions/index.tsx +++ b/src/pages/sessions/index.tsx @@ -7,8 +7,8 @@ import EmptyState from "components/EmptyState"; import Loader from "components/Loader"; import Page from "components/Page"; import { getAgentIcon } from "lib/agents"; -import { chatTabId } from "lib/chatTabId"; import { copyToClipboard } from "lib/clipboard"; +import { openChatPage } from "lib/navigate"; import { getResumeCommand } from "lib/resumeCommand"; import { formatRelativeTime } from "lib/utils"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -206,26 +206,17 @@ export default function ChatSessionsPage({ title: string; workspacePath: string; }) => { - const tabId = chatTabId(backend, opts.sessionId); - const ChatConversationPage = await import("pages/chat"); - pushPage( - tabId, - , - ); + await openChatPage({ + agentId: backend, + agent, + sessionId: opts.sessionId, + title: opts.title, + workspacePath: opts.workspacePath, + agentAvailable, + createOnFirstMessage: !opts.sessionId, + }); }, - [agent?.capabilities, agentAvailable, agent.name, agent.title, backend], + [agent, agentAvailable, backend], ); const openNewChatForWorkspace = useCallback( diff --git a/src/tabs/home/ConnectionInfo.tsx b/src/tabs/home/ConnectionInfo.tsx index 40c898d..062f066 100644 --- a/src/tabs/home/ConnectionInfo.tsx +++ b/src/tabs/home/ConnectionInfo.tsx @@ -1,5 +1,4 @@ import "./ConnectionInfo.scss"; -import { pushPage } from "App"; import { type AiBackend, type HostUpdateResultMsg, @@ -9,6 +8,7 @@ import dialog from "bridge/dialog"; import AgentIcon from "components/AgentIcon"; import Mascot from "components/Mascot"; import appConfig from "lib/appConfig"; +import { openSessionsPage, openSystemMonitorPage } from "lib/navigate"; import toast from "lib/toast"; import { getPlatformIcon } from "lib/utils"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -42,8 +42,7 @@ export default function ConnectionInfo({ const hiddenAgentCount = availableAgents.length - visibleAgents.length; const openSysmon = useCallback(async () => { - const SysmonPage = await import("pages/sysmon"); - pushPage("system-monitor", ); + await openSystemMonitorPage(); }, []); const onDisconnect = useCallback(async () => { @@ -122,11 +121,7 @@ export default function ConnectionInfo({ const openSessions = useCallback( async (backend: AiBackend) => { - const ChatSessionsPage = await import("pages/sessions"); - pushPage( - `ai-${backend}`, - , - ); + await openSessionsPage(backend, agents[backend]); }, [agents], ); diff --git a/src/tabs/home/index.tsx b/src/tabs/home/index.tsx index 3926d3e..d3a4d02 100644 --- a/src/tabs/home/index.tsx +++ b/src/tabs/home/index.tsx @@ -10,8 +10,8 @@ import Scanner from "components/Scanner"; import { AnimatePresence, domMax, LazyMotion, m } from "framer-motion"; import { getAgentIcon } from "lib/agents"; import { useAuth } from "lib/auth"; -import { chatTabId } from "lib/chatTabId"; import { copyToClipboard } from "lib/clipboard"; +import { openChatPage } from "lib/navigate"; import { dismissNotice, getUndismissedNotices, type Notice } from "lib/notices"; import { shouldPromptForRating } from "lib/ratingService"; import { getResumeCommand } from "lib/resumeCommand"; @@ -301,24 +301,13 @@ async function openSession( session: SessionActivity, agent?: ReturnType["agents"][string], ) { - const agentName = agent?.name ?? session.agentId; - const tabId = chatTabId(session.agentId, session.sessionId); - const ChatConversationPage = await import("pages/chat"); - pushPage( - tabId, - , - ); + await openChatPage({ + agentId: session.agentId, + agent, + sessionId: session.sessionId, + title: sessionDisplayTitle(session), + workspacePath: session.workspacePath ?? "", + }); } function copySessionId(sessionId: string) { diff --git a/src/tabs/more/index.tsx b/src/tabs/more/index.tsx index eed9078..916234c 100644 --- a/src/tabs/more/index.tsx +++ b/src/tabs/more/index.tsx @@ -1,8 +1,8 @@ import { pushPage } from "App"; import RatingDialog from "components/RatingDialog"; import TabPageHeader from "components/TabPageHeader"; +import { openPortsPage } from "lib/navigate"; import AboutPage from "pages/about"; -import PortsPage from "pages/ports"; import ReachOutPage from "pages/reach-out"; import SettingsPage from "pages/settings"; import { useState } from "react"; @@ -25,7 +25,7 @@ export default function MoreTab() { description: "View and manage open ports", icon: "icon-power-cord", onTap: () => { - pushPage("ports", , { showConnectionBanner: false }); + openPortsPage().catch(console.error); }, }, { diff --git a/src/tabs/projects/ProjectList.tsx b/src/tabs/projects/ProjectList.tsx index 84454bb..7ab0eb4 100644 --- a/src/tabs/projects/ProjectList.tsx +++ b/src/tabs/projects/ProjectList.tsx @@ -3,7 +3,7 @@ import dialog from "bridge/dialog"; import AppMenu from "components/AppMenu"; import Loader from "components/Loader"; import { getAgentIcon } from "lib/agents"; -import { chatTabId } from "lib/chatTabId"; +import { openChatPage, openGitClientPage } from "lib/navigate"; import { useCallback, useState } from "react"; import { type ProjectInfo, useShellular } from "state"; import type { AcpAgentInfo } from "state/acp"; @@ -51,35 +51,20 @@ export default function ProjectList({ projects, adding }: Props) { ); return; } - const tabId = chatTabId(agent.id, ""); - const ChatConversationPage = await import("pages/chat"); - pushPage( - tabId, - , - ); + await openChatPage({ + agentId: agent.id, + agent, + sessionId: "", + title: "New Chat", + workspacePath: project.path, + createOnFirstMessage: true, + }); }, [], ); const openGitClient = useCallback(async (project: ProjectInfo) => { - const GitClientPage = await import("pages/git-client"); - pushPage( - `git-client-${project.path}`, - , - ); + await openGitClientPage(project.path, project.name); }, []); return ( From 255224403f2e5f7a0b6bf4d2b63c2d54d5efe006 Mon Sep 17 00:00:00 2001 From: Jan Karres Date: Tue, 18 Aug 2026 23:49:23 +0200 Subject: [PATCH 4/7] feat(startup): add the cold-start runner --- src/state/startup.ts | 296 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 src/state/startup.ts diff --git a/src/state/startup.ts b/src/state/startup.ts new file mode 100644 index 0000000..0a80fa4 --- /dev/null +++ b/src/state/startup.ts @@ -0,0 +1,296 @@ +import { formatConnectionString } from "lib/e2ee"; +import { getSavedHosts } from "lib/machines"; +import { loadSettings } from "lib/settings"; +import { + describeStartupTarget, + pickResumableSession, + planStartupConnect, + planStartupOpen, + startupTargetNeedsAgent, + startupTargetNeedsProject, +} from "lib/startupPlan"; +import toast from "lib/toast"; +import { type AcpAgentInfo, acpListSessions } from "state/acp"; +import type { Project } from "state/projects"; +import { getConnectionSnapshot, subscribeState } from "./connection"; + +// ─── Types ──────────────────────────────────────────────────── + +export type StartupPhase = "idle" | "connecting" | "opening" | "done"; + +export interface StartupSnapshot { + phase: StartupPhase; + /** The line the banner shows while the sequence runs. */ + message: string; +} + +/** + * Live views onto the provider's state. They are getters rather than values + * because the runner starts once and then waits: it has to see what the latest + * render produced, not what existed at mount. + */ +export interface StartupContext { + connect: (token: string) => Promise; + getAgents: () => Record; + getProjects: () => Project[]; +} + +// ─── State ──────────────────────────────────────────────────── + +const CONNECT_TIMEOUT_MS = 20_000; +const CONTEXT_TIMEOUT_MS = 10_000; +const CONTEXT_POLL_MS = 100; +const TOAST_MS = 3400; + +const IDLE: StartupSnapshot = { phase: "idle", message: "" }; +const DONE: StartupSnapshot = { phase: "done", message: "" }; + +const listeners = new Set<() => void>(); +let snapshot: StartupSnapshot = IDLE; +/** + * The module lives exactly as long as the app process, which is the whole + * implementation of "cold start only": a resume, a `recover()` reconnect or a + * re-mount all find this already set and stand down. + */ +let hasRun = false; +let cancelled = false; + +function emit() { + for (const listener of Array.from(listeners)) listener(); +} + +function setSnapshot(next: StartupSnapshot) { + snapshot = next; + emit(); +} + +export function subscribeStartup(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getStartupSnapshot(): StartupSnapshot { + return snapshot; +} + +/** + * Stops the sequence at the next await boundary. The connection is kept: it is + * useful either way, and dropping it would punish someone for wanting to do + * something else with the host that is now up. + */ +export function cancelStartup() { + if (snapshot.phase !== "connecting" && snapshot.phase !== "opening") return; + cancelled = true; + setSnapshot(DONE); +} + +// ─── Waiting ────────────────────────────────────────────────── + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function waitForConnection(timeoutMs: number): Promise { + return new Promise((resolve) => { + if (getConnectionSnapshot().connectionStatus === "connected") { + resolve(true); + return; + } + + let settled = false; + // The status is still "disconnected" for the moment it takes connect() to + // resolve the server URL, so a bare "disconnected" is not yet a failure. + // It only becomes one once an attempt has actually started. + let sawAttempt = false; + + const finish = (connected: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + unsubscribe(); + resolve(connected); + }; + + const timer = setTimeout(() => finish(false), timeoutMs); + const unsubscribe = subscribeState(() => { + if (cancelled) { + finish(false); + return; + } + const { connectionStatus } = getConnectionSnapshot(); + if (connectionStatus === "connected") { + finish(true); + } else if ( + connectionStatus === "connecting" || + connectionStatus === "reconnecting" + ) { + sawAttempt = true; + } else if (sawAttempt) { + finish(false); + } + }); + }); +} + +/** + * Agents and projects live in React state inside the provider, not in an + * external store, so there is nothing to subscribe to and this polls. Both are + * filled by the existing post-connect callback, so the wait is short in + * practice and the timeout only matters when the host genuinely has none. + */ +async function waitFor( + predicate: () => boolean, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate() && !cancelled && Date.now() < deadline) { + await delay(CONTEXT_POLL_MS); + } +} + +// ─── The sequence ───────────────────────────────────────────── + +function fail(reason: string) { + toast(reason, TOAST_MS); + setSnapshot(DONE); +} + +export async function runStartup(context: StartupContext): Promise { + if (hasRun) return; + hasRun = true; + + const { startup } = await loadSettings(); + const connectPlan = planStartupConnect(startup, await getSavedHosts()); + + if (connectPlan.kind === "none") { + // No reason means auto-connect is simply off, which is the default path + // and has to stay silent. + if (connectPlan.reason) fail(connectPlan.reason); + else setSnapshot(DONE); + return; + } + + const { host } = connectPlan; + const hostLabel = host.alias || host.hostname; + setSnapshot({ phase: "connecting", message: `Connecting to ${hostLabel}` }); + + if (getConnectionSnapshot().connectionStatus !== "connected") { + try { + await context.connect( + formatConnectionString(host.hostId, host.encryptionKey), + ); + } catch (err) { + if (cancelled) return; + fail(`Could not reach ${hostLabel}: ${(err as Error).message}`); + return; + } + if (cancelled) return; + if (!(await waitForConnection(CONNECT_TIMEOUT_MS))) { + if (cancelled) return; + fail(`Could not reach ${hostLabel}`); + return; + } + } + if (cancelled) return; + + if (startup.target === "home") { + setSnapshot(DONE); + return; + } + + setSnapshot({ + phase: "opening", + message: `Opening ${describeStartupTarget(startup)}`, + }); + + if (startupTargetNeedsAgent(startup.target)) { + await waitFor( + () => Object.keys(context.getAgents()).length > 0, + CONTEXT_TIMEOUT_MS, + ); + } + if (cancelled) return; + if (startupTargetNeedsProject(startup.target)) { + await waitFor(() => context.getProjects().length > 0, CONTEXT_TIMEOUT_MS); + } + if (cancelled) return; + + const openPlan = planStartupOpen(startup, { + agents: context.getAgents(), + projects: context.getProjects(), + }); + + if (openPlan.kind === "none") { + setSnapshot(DONE); + return; + } + if (openPlan.kind === "unavailable") { + fail(openPlan.reason); + return; + } + + // Imported here rather than at the top: App mounts the runner, and + // lib/navigate imports pushPage from App, so a static import would close + // that cycle at module-evaluation time. + const navigate = await import("lib/navigate"); + if (cancelled) return; + + try { + switch (openPlan.kind) { + case "terminal": + navigate.openTerminalTab(); + break; + case "system-monitor": + await navigate.openSystemMonitorPage(); + break; + case "ports": + await navigate.openPortsPage(); + break; + case "git-client": + await navigate.openGitClientPage( + openPlan.project.path, + openPlan.project.name, + ); + break; + case "new-chat": + await navigate.openChatPage({ + agentId: openPlan.agent.id, + agent: openPlan.agent, + sessionId: "", + title: "New Chat", + workspacePath: openPlan.project.path, + createOnFirstMessage: true, + }); + break; + case "last-chat": { + const { sessions } = await acpListSessions( + openPlan.agent.id, + openPlan.project.path, + openPlan.agent, + ); + if (cancelled) return; + const session = pickResumableSession(sessions); + if (!session?.id) { + fail(`No previous chat in ${openPlan.project.name}`); + return; + } + await navigate.openChatPage({ + agentId: openPlan.agent.id, + agent: openPlan.agent, + sessionId: session.id, + title: session.title ?? session.id, + workspacePath: session.workspacePath || openPlan.project.path, + }); + break; + } + } + } catch (err) { + console.error("[Startup] Failed to open the start target", err); + fail(`Could not open ${describeStartupTarget(startup)}`); + return; + } + + setSnapshot(DONE); +} From 7a2609e01e6c5bd4fe3ec94f58e8c9174da0c1dd Mon Sep 17 00:00:00 2001 From: Jan Karres Date: Tue, 18 Aug 2026 23:49:57 +0200 Subject: [PATCH 5/7] feat(startup): run the rule on cold start and show a cancellable banner --- src/App.tsx | 2 ++ src/components/StartupBanner.tsx | 49 ++++++++++++++++++++++++++++++++ src/components/StartupRunner.tsx | 33 +++++++++++++++++++++ src/tabs/home/index.tsx | 2 ++ 4 files changed, 86 insertions(+) create mode 100644 src/components/StartupBanner.tsx create mode 100644 src/components/StartupRunner.tsx diff --git a/src/App.tsx b/src/App.tsx index 10b0021..4f6a534 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import browser from "bridge/browser"; import AppDialogHost from "components/AppDialog"; import ConnectionStatus from "components/ConnectionStatus"; import EmptyState from "components/EmptyState"; +import StartupRunner from "components/StartupRunner"; import actionStack from "lib/actionStack"; import { AuthProvider, useAuth } from "lib/auth"; import * as store from "lib/store"; @@ -207,6 +208,7 @@ function AuthenticatedApp() { + {pageStack.map(({ id, element }) => { const isClosing = closingIds.has(id); const isVisible = id === topNonClosingPage?.id || isClosing; diff --git a/src/components/StartupBanner.tsx b/src/components/StartupBanner.tsx new file mode 100644 index 0000000..1d3f157 --- /dev/null +++ b/src/components/StartupBanner.tsx @@ -0,0 +1,49 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { + cancelStartup, + getStartupSnapshot, + subscribeStartup, +} from "state/startup"; + +/** + * The cancellable strip shown while the startup rule runs. It lives in the + * Home tab's status slot rather than at the app root, because the rule only + * ever runs while Home is on screen. + */ +export default function StartupBanner() { + const { phase, message } = useSyncExternalStore( + subscribeStartup, + getStartupSnapshot, + ); + + // TabView renders only the active tab, so leaving Home unmounts this and + // cancels the sequence. Someone who has started doing something else should + // not get a chat opened on top of them. A pushed page does not unmount the + // tab view, so opening the target does not cancel itself. + useEffect(() => cancelStartup, []); + + if (phase !== "connecting" && phase !== "opening") return null; + + return ( +
+
+ ); +} diff --git a/src/components/StartupRunner.tsx b/src/components/StartupRunner.tsx new file mode 100644 index 0000000..e90f515 --- /dev/null +++ b/src/components/StartupRunner.tsx @@ -0,0 +1,33 @@ +import { useEffect, useRef } from "react"; +import { useShellular } from "state"; +import { runStartup } from "state/startup"; + +/** + * Starts the configured startup rule, once per app process. Renders nothing, + * the way AppDialogHost does; the visible half is StartupBanner in the Home + * tab. Mounted in App's authenticated, non-onboarding branch rather than + * inside the provider, because ShellularProvider also wraps the onboarding + * page and the rule must not fire during first run. + */ +export default function StartupRunner() { + const { connect, agents, projects } = useShellular(); + + // The runner starts once and then waits, so it needs the values from the + // latest render rather than the ones captured at mount. + const connectRef = useRef(connect); + connectRef.current = connect; + const agentsRef = useRef(agents); + agentsRef.current = agents; + const projectsRef = useRef(projects); + projectsRef.current = projects; + + useEffect(() => { + runStartup({ + connect: (token) => connectRef.current(token), + getAgents: () => agentsRef.current, + getProjects: () => projectsRef.current, + }).catch((err) => console.error("[Startup]", err)); + }, []); + + return null; +} diff --git a/src/tabs/home/index.tsx b/src/tabs/home/index.tsx index d3a4d02..0b87240 100644 --- a/src/tabs/home/index.tsx +++ b/src/tabs/home/index.tsx @@ -7,6 +7,7 @@ import NoticeDialog from "components/NoticeDialog"; import OfflineBanner from "components/OfflineBanner"; import RatingDialog from "components/RatingDialog"; import Scanner from "components/Scanner"; +import StartupBanner from "components/StartupBanner"; import { AnimatePresence, domMax, LazyMotion, m } from "framer-motion"; import { getAgentIcon } from "lib/agents"; import { useAuth } from "lib/auth"; @@ -136,6 +137,7 @@ export default function HomeTab() {
+ {isOnline && hostInfo && } {isOnline && hostInfo && visibleActiveSessions.length > 0 && (
From 995abf220855cdd65eb736c6a8dab7972a6f2c59 Mon Sep 17 00:00:00 2001 From: Jan Karres Date: Tue, 18 Aug 2026 23:51:03 +0200 Subject: [PATCH 6/7] feat(settings): add the Startup category --- src/pages/settings/index.tsx | 187 +++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index c0606d7..e5816dd 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -4,16 +4,26 @@ import Page from "components/Page"; import { DEFAULT_EDITOR_SETTINGS, DEFAULT_SERVER_SETTINGS, + DEFAULT_STARTUP_SETTINGS, DEFAULT_TERMINAL_SETTINGS, loadSettings, MONOSPACE_FONT_FAMILY_OPTIONS, type ServerProtocol, + type StartupConnectMode, + type StartupTarget, saveSettings, type TerminalCursorStyle, } from "lib/settings"; +import { + STARTUP_CONNECT_OPTIONS, + STARTUP_TARGET_OPTIONS, + startupTargetNeedsAgent, + startupTargetNeedsProject, +} from "lib/startupPlan"; import * as store from "lib/store"; import toast from "lib/toast"; import React, { useEffect, useState } from "react"; +import { useShellular } from "state"; import themes from "themes"; import "./style.scss"; @@ -293,6 +303,23 @@ export default function SettingsPage() { DEFAULT_TERMINAL_SETTINGS.letterSpacing, ); + const { savedHosts, agents, projects, connectionStatus } = useShellular(); + const [startupConnect, setStartupConnect] = useState( + DEFAULT_STARTUP_SETTINGS.connect, + ); + const [startupHostId, setStartupHostId] = useState( + DEFAULT_STARTUP_SETTINGS.hostId, + ); + const [startupTarget, setStartupTarget] = useState( + DEFAULT_STARTUP_SETTINGS.target, + ); + const [startupAgentId, setStartupAgentId] = useState( + DEFAULT_STARTUP_SETTINGS.agentId, + ); + const [startupProjectPath, setStartupProjectPath] = useState( + DEFAULT_STARTUP_SETTINGS.projectPath, + ); + const [hapticFeedback, setHapticFeedback] = useState(true); const [isSavingServer, setIsSavingServer] = useState(false); const [testOnboarding, setTestOnboarding] = useState(false); @@ -327,6 +354,11 @@ export default function SettingsPage() { setTerminalScrollback(s.terminal.scrollback); setTerminalLetterSpacing(s.terminal.letterSpacing); setHapticFeedback(s.hapticFeedback); + setStartupConnect(s.startup.connect); + setStartupHostId(s.startup.hostId); + setStartupTarget(s.startup.target); + setStartupAgentId(s.startup.agentId); + setStartupProjectPath(s.startup.projectPath); }); }, []); @@ -401,6 +433,33 @@ export default function SettingsPage() { await persistSettings({ terminal: { letterSpacing: value } }); } + async function handleStartupConnectChange(value: string) { + const connect = value as StartupConnectMode; + setStartupConnect(connect); + await persistSettings({ startup: { connect } }); + } + + async function handleStartupHostChange(hostId: string) { + setStartupHostId(hostId); + await persistSettings({ startup: { hostId } }); + } + + async function handleStartupTargetChange(value: string) { + const target = value as StartupTarget; + setStartupTarget(target); + await persistSettings({ startup: { target } }); + } + + async function handleStartupAgentChange(agentId: string) { + setStartupAgentId(agentId); + await persistSettings({ startup: { agentId } }); + } + + async function handleStartupProjectChange(projectPath: string) { + setStartupProjectPath(projectPath); + await persistSettings({ startup: { projectPath } }); + } + async function handleHapticFeedbackChange(value: boolean) { setHapticFeedback(value); await persistSettings({ hapticFeedback: value }); @@ -438,6 +497,7 @@ export default function SettingsPage() { const sidebarCategories = [ { id: "look-and-feel", label: "Look & Feel" }, + { id: "startup", label: "Startup" }, { id: "editor", label: "Editor" }, { id: "terminal", label: "Terminal" }, { id: "network", label: "Network" }, @@ -455,6 +515,34 @@ export default function SettingsPage() { description: "Vibrates on touch interactions.", }, ]); + const showStartupSettings = matchesSettingsSearch(searchQuery, [ + { + title: "Auto-connect", + description: "Which host the app connects to when it is opened.", + }, + { title: "Host", description: "The host to connect to on every launch." }, + { title: "Open", description: "What the app opens once the host is up." }, + { title: "Agent", description: "The agent the chat starts with." }, + { + title: "Project", + description: "The folder the chat or git client opens.", + }, + ]); + const autoConnectOff = startupConnect === "off"; + const isConnected = connectionStatus === "connected"; + const startupHostOptions = savedHosts.map((host) => ({ + value: host.hostId, + label: + host.alias || + `${host.username ? `${host.username}@` : ""}${host.hostname}`, + })); + const startupAgentOptions = Object.values(agents) + .filter((agent) => agent.available) + .map((agent) => ({ value: agent.id, label: agent.title || agent.name })); + const startupProjectOptions = projects.map((project) => ({ + value: project.path, + label: project.name, + })); const showEditorSettings = matchesSettingsSearch(searchQuery, [ { title: "Font Size", description: "Controls the font size in pixels." }, { @@ -609,6 +697,105 @@ export default function SettingsPage() {
)} + {((isSearching && showStartupSettings) || + (!isSearching && activeTab === "startup")) && ( +
+ {isSearching && ( +

+ Startup +

+ )} + + + } + /> + {startupConnect === "pinned-host" && ( + + } + /> + )} + + + + } + /> + {startupTargetNeedsAgent(startupTarget) && ( + + } + /> + )} + {startupTargetNeedsProject(startupTarget) && ( + + } + /> + )} + +
+ )} + {((isSearching && showEditorSettings) || (!isSearching && activeTab === "editor")) && (
From ede76674b55f061e010b9f6043e951afe21455a0 Mon Sep 17 00:00:00 2001 From: Jan Karres Date: Wed, 19 Aug 2026 11:27:26 +0200 Subject: [PATCH 7/7] refactor(navigation): route the bookmarked-sessions chat push through lib/navigate --- src/pages/bookmark-sessions/index.tsx | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/pages/bookmark-sessions/index.tsx b/src/pages/bookmark-sessions/index.tsx index 6fdebeb..d9e18ae 100644 --- a/src/pages/bookmark-sessions/index.tsx +++ b/src/pages/bookmark-sessions/index.tsx @@ -1,10 +1,9 @@ import "pages/sessions/style.scss"; -import { pushPage } from "App"; import AppMenu from "components/AppMenu"; import EmptyState from "components/EmptyState"; import Page from "components/Page"; import { getAgentIcon } from "lib/agents"; -import { chatTabId } from "lib/chatTabId"; +import { openChatPage } from "lib/navigate"; import { formatRelativeTime } from "lib/utils"; import { useCallback } from "react"; import { useShellular } from "state"; @@ -29,23 +28,13 @@ export default function BookmarkSessionsPage() { async (bookmark: BookmarkedSession) => { const agent = agents[bookmark.agentId]; if (!agent?.available) return; - const tabId = chatTabId(bookmark.agentId, bookmark.sessionId); - const ChatConversationPage = await import("pages/chat"); - pushPage( - tabId, - , - ); + await openChatPage({ + agentId: bookmark.agentId, + agent, + sessionId: bookmark.sessionId, + title: bookmark.title, + workspacePath: bookmark.workspacePath, + }); }, [agents], );