Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1230186
Merge pull request #3 from shellular-org/dev
biraj21 Jun 16, 2026
9c5953f
Merge pull request #6 from shellular-org/dev
biraj21 Jun 18, 2026
1a47c04
Merge pull request #9 from shellular-org/dev
biraj21 Jun 24, 2026
cab21e1
Biraj/cli version and update (#14) (#15)
biraj21 Jul 1, 2026
6fe2ced
Merge pull request #19 from shellular-org/dev
biraj21 Jul 3, 2026
e474f6a
v0.0.31: Merge pull request #22 from shellular-org/dev
biraj21 Jul 4, 2026
8b07e83
Merge pull request #24 from shellular-org/dev
biraj21 Jul 6, 2026
898570c
v0.0.33: Merge pull request #30 from shellular-org/dev
biraj21 Jul 7, 2026
48bacf5
v0.0.34: Merge pull request #33 from shellular-org/dev
biraj21 Jul 12, 2026
89bfd43
v0.0.35: Merge pull request #34 from shellular-org/dev
biraj21 Jul 12, 2026
cda09be
v0.0.36: Merge pull request #38 from shellular-org/dev
biraj21 Jul 18, 2026
f73c833
v0.0.37: Merge pull request #43 from shellular-org/dev
biraj21 Jul 21, 2026
f119e7f
Merge pull request #48 from shellular-org/dev
biraj21 Jul 31, 2026
cea2b0d
v0.0.39: Merge pull request #51 from shellular-org/dev
biraj21 Jul 31, 2026
42d3004
v0.0.40 (#53)
biraj21 Aug 10, 2026
f9653be
v0.0.41: Merge pull request #58 from shellular-org/dev
biraj21 Aug 14, 2026
f996b5c
v0.0.42: Merge pull request #61 from shellular-org/dev
biraj21 Aug 18, 2026
db69dcc
feat(settings): add a startup settings group
jankarres Aug 18, 2026
0bcb23e
feat(startup): add the pure startup planner
jankarres Aug 18, 2026
e79b32d
refactor(navigation): move the shared page pushes into lib/navigate
jankarres Aug 18, 2026
2552244
feat(startup): add the cold-start runner
jankarres Aug 18, 2026
7a2609e
feat(startup): run the rule on cold start and show a cancellable banner
jankarres Aug 18, 2026
995abf2
feat(settings): add the Startup category
jankarres Aug 18, 2026
ede7667
refactor(navigation): route the bookmarked-sessions chat push through…
jankarres Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -207,6 +208,7 @@ function AuthenticatedApp() {
<ShellularProvider>
<TabView />
<AppDialogHost />
<StartupRunner />
{pageStack.map(({ id, element }) => {
const isClosing = closingIds.has(id);
const isVisible = id === topNonClosingPage?.id || isClosing;
Expand Down
49 changes: 49 additions & 0 deletions src/components/StartupBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="mx-4 mb-3 flex items-center gap-3 rounded-2xl border border-card-border bg-popup-background px-4 py-3 shadow-[var(--shadow)]"
role="status"
aria-live="polite"
>
<span
className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-accent"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate text-[13px] text-primary-text">
{message}
</span>
<button
type="button"
className="haptic-trigger shrink-0 rounded-lg px-2 py-1 text-[12px] font-bold text-secondary-text transition-colors duration-150 active:text-primary-text"
onClick={cancelStartup}
>
Cancel
</button>
</div>
);
}
33 changes: 33 additions & 0 deletions src/components/StartupRunner.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
95 changes: 95 additions & 0 deletions src/lib/navigate.tsx
Original file line number Diff line number Diff line change
@@ -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<void> {
const id = tabId ?? chatTabId(agentId, sessionId);
const assistantName = agent?.name ?? agentId;
const ChatConversationPage = await import("pages/chat");
pushPage(
id,
<ChatConversationPage.default
chatTabId={id}
sessionId={sessionId}
title={title}
agentId={agentId}
workspacePath={workspacePath}
assistantName={assistantName}
agentAvailable={agentAvailable ?? agent?.available ?? true}
unavailableMessage={`${assistantName} is not available on this device.`}
providerName={agent?.title || agent?.name || agentId}
agentCapabilities={agent?.capabilities}
createOnFirstMessage={createOnFirstMessage ?? false}
/>,
);
}

export async function openSessionsPage(
backend: AiBackend,
agent: AcpAgentInfo,
): Promise<void> {
const ChatSessionsPage = await import("pages/sessions");
pushPage(
`ai-${backend}`,
<ChatSessionsPage.default backend={backend} agent={agent} />,
);
}

export async function openGitClientPage(
projectPath: string,
projectName: string,
): Promise<void> {
const GitClientPage = await import("pages/git-client");
pushPage(
`git-client-${projectPath}`,
<GitClientPage.default
projectPath={projectPath}
projectName={projectName}
/>,
);
}

export async function openSystemMonitorPage(): Promise<void> {
const SysmonPage = await import("pages/sysmon");
pushPage("system-monitor", <SysmonPage.default />);
}

export async function openPortsPage(): Promise<void> {
const PortsPage = await import("pages/ports");
pushPage("ports", <PortsPage.default />, { showConnectionBanner: false });
}

export function openTerminalTab(): void {
toToTab("terminals");
}
88 changes: 88 additions & 0 deletions src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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<StartupSettings> | 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<AppSettings> | null | undefined,
): AppSettings {
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading