Compare commits

..
19 changed files with 87 additions and 234 deletions

No files matched your search

-1
View File
@@ -57,7 +57,6 @@
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section. - Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags. - PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches. - PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`. - PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor. - If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When working on a PR: add a changelog entry with the PR number and thank the contributor. - When working on a PR: add a changelog entry with the PR number and thank the contributor.
-2
View File
@@ -7,12 +7,10 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Android: remove legacy bridge transport code now that nodes use the gateway protocol. - Android: remove legacy bridge transport code now that nodes use the gateway protocol.
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects. - Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
### Fixes ### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean. - Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest. - UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- UI: centralize Control UI session routing + URL sync so chat deep links stay stable. (#1288) — thanks @bradleypriest.
## 2026.1.19-2 ## 2026.1.19-2
+1 -3
View File
@@ -28,11 +28,9 @@ export AWS_REGION="us-east-1"
# Optional: # Optional:
export AWS_SESSION_TOKEN="..." export AWS_SESSION_TOKEN="..."
export AWS_PROFILE="your-profile" export AWS_PROFILE="your-profile"
# Optional (Bedrock API key/bearer token):
export AWS_BEARER_TOKEN_BEDROCK="..."
``` ```
2) Add a Bedrock provider and model to your config (no `apiKey` required): 2) Add a Bedrock provider and model to your config:
```json5 ```json5
{ {
-1
View File
@@ -31,7 +31,6 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
- [Vercel AI Gateway](/providers/vercel-ai-gateway) - [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot) - [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- [OpenCode Zen](/providers/opencode) - [OpenCode Zen](/providers/opencode)
- [Amazon Bedrock](/bedrock)
- [Z.AI](/providers/zai) - [Z.AI](/providers/zai)
- [GLM models](/providers/glm) - [GLM models](/providers/glm)
- [MiniMax](/providers/minimax) - [MiniMax](/providers/minimax)
+1 -1
View File
@@ -39,7 +39,7 @@ export function resolveModel(
if (!model) { if (!model) {
const providers = cfg?.models?.providers ?? {}; const providers = cfg?.models?.providers ?? {};
const inlineModels = const inlineModels =
providers[provider]?.models?.map((entry) => ({ ...entry, provider })) ?? providers[provider]?.models ??
Object.values(providers) Object.values(providers)
.flatMap((entry) => entry?.models ?? []) .flatMap((entry) => entry?.models ?? [])
.map((entry) => ({ ...entry, provider })); .map((entry) => ({ ...entry, provider }));
+1 -1
View File
@@ -16,7 +16,7 @@ export async function checkGatewayHealth(params: {
typeof params.timeoutMs === "number" && params.timeoutMs > 0 ? params.timeoutMs : 10_000; typeof params.timeoutMs === "number" && params.timeoutMs > 0 ? params.timeoutMs : 10_000;
let healthOk = false; let healthOk = false;
try { try {
await healthCommand({ json: false, timeoutMs, config: params.cfg }, params.runtime); await healthCommand({ json: false, timeoutMs }, params.runtime);
healthOk = true; healthOk = true;
} catch (err) { } catch (err) {
const message = String(err); const message = String(err);
+1 -10
View File
@@ -10,7 +10,7 @@ import {
resolveHooksGmailModel, resolveHooksGmailModel,
} from "../agents/model-selection.js"; } from "../agents/model-selection.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { CONFIG_PATH_CLAWDBOT, readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
@@ -269,14 +269,5 @@ export async function doctorCommand(
} }
} }
const finalSnapshot = await readConfigFileSnapshot();
if (finalSnapshot.exists && !finalSnapshot.valid) {
runtime.error("Invalid config:");
for (const issue of finalSnapshot.issues) {
const path = issue.path || "<root>";
runtime.error(`- ${path}: ${issue.message}`);
}
}
outro("Doctor complete."); outro("Doctor complete.");
} }
+3 -5
View File
@@ -3,7 +3,6 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js"; import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
import { withProgress } from "../cli/progress.js"; import { withProgress } from "../cli/progress.js";
import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
@@ -502,10 +501,9 @@ export async function getHealthSnapshot(params?: {
} }
export async function healthCommand( export async function healthCommand(
opts: { json?: boolean; timeoutMs?: number; verbose?: boolean; config?: ClawdbotConfig }, opts: { json?: boolean; timeoutMs?: number; verbose?: boolean },
runtime: RuntimeEnv, runtime: RuntimeEnv,
) { ) {
const cfg = opts.config ?? loadConfig();
// Always query the running gateway; do not open a direct Baileys socket here. // Always query the running gateway; do not open a direct Baileys socket here.
const summary = await withProgress( const summary = await withProgress(
{ {
@@ -518,7 +516,6 @@ export async function healthCommand(
method: "health", method: "health",
params: opts.verbose ? { probe: true } : undefined, params: opts.verbose ? { probe: true } : undefined,
timeoutMs: opts.timeoutMs, timeoutMs: opts.timeoutMs,
config: cfg,
}), }),
); );
// Gateway reachability defines success; channel issues are reported but not fatal here. // Gateway reachability defines success; channel issues are reported but not fatal here.
@@ -529,12 +526,13 @@ export async function healthCommand(
} else { } else {
const debugEnabled = isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_HEALTH); const debugEnabled = isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_HEALTH);
if (opts.verbose) { if (opts.verbose) {
const details = buildGatewayConnectionDetails({ config: cfg }); const details = buildGatewayConnectionDetails();
runtime.log(info("Gateway connection:")); runtime.log(info("Gateway connection:"));
for (const line of details.message.split("\n")) { for (const line of details.message.split("\n")) {
runtime.log(` ${line}`); runtime.log(` ${line}`);
} }
} }
const cfg = loadConfig();
const localAgents = resolveAgentOrder(cfg); const localAgents = resolveAgentOrder(cfg);
const defaultAgentId = summary.defaultAgentId ?? localAgents.defaultAgentId; const defaultAgentId = summary.defaultAgentId ?? localAgents.defaultAgentId;
const agents = Array.isArray(summary.agents) ? summary.agents : []; const agents = Array.isArray(summary.agents) ? summary.agents : [];
+1 -2
View File
@@ -3,8 +3,7 @@ export type ModelApi =
| "openai-responses" | "openai-responses"
| "anthropic-messages" | "anthropic-messages"
| "google-generative-ai" | "google-generative-ai"
| "github-copilot" | "github-copilot";
| "bedrock-converse-stream";
export type ModelCompatConfig = { export type ModelCompatConfig = {
supportsStore?: boolean; supportsStore?: boolean;
-1
View File
@@ -8,7 +8,6 @@ export const ModelApiSchema = z.union([
z.literal("anthropic-messages"), z.literal("anthropic-messages"),
z.literal("google-generative-ai"), z.literal("google-generative-ai"),
z.literal("github-copilot"), z.literal("github-copilot"),
z.literal("bedrock-converse-stream"),
]); ]);
export const ModelCompatSchema = z export const ModelCompatSchema = z
+1 -2
View File
@@ -22,7 +22,6 @@ export type CallGatewayOptions = {
url?: string; url?: string;
token?: string; token?: string;
password?: string; password?: string;
config?: ClawdbotConfig;
method: string; method: string;
params?: unknown; params?: unknown;
expectFinal?: boolean; expectFinal?: boolean;
@@ -110,7 +109,7 @@ export function buildGatewayConnectionDetails(
export async function callGateway<T = unknown>(opts: CallGatewayOptions): Promise<T> { export async function callGateway<T = unknown>(opts: CallGatewayOptions): Promise<T> {
const timeoutMs = opts.timeoutMs ?? 10_000; const timeoutMs = opts.timeoutMs ?? 10_000;
const config = opts.config ?? loadConfig(); const config = loadConfig();
const isRemoteMode = config.gateway?.mode === "remote"; const isRemoteMode = config.gateway?.mode === "remote";
const remote = isRemoteMode ? config.gateway?.remote : undefined; const remote = isRemoteMode ? config.gateway?.remote : undefined;
const urlOverride = const urlOverride =
+3 -10
View File
@@ -22,17 +22,9 @@ import {
installGatewayTestHooks(); installGatewayTestHooks();
async function waitForSignal(check: () => boolean, timeoutMs = 2000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (check()) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error("timeout");
}
describe("gateway update.run", () => { describe("gateway update.run", () => {
it("writes sentinel and schedules restart", async () => { it("writes sentinel and schedules restart", async () => {
vi.useFakeTimers();
const sigusr1 = vi.fn(); const sigusr1 = vi.fn();
process.on("SIGUSR1", sigusr1); process.on("SIGUSR1", sigusr1);
@@ -57,7 +49,7 @@ describe("gateway update.run", () => {
); );
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
await waitForSignal(() => sigusr1.mock.calls.length > 0); await vi.advanceTimersByTimeAsync(0);
expect(sigusr1).toHaveBeenCalled(); expect(sigusr1).toHaveBeenCalled();
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json"); const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
@@ -71,5 +63,6 @@ describe("gateway update.run", () => {
ws.close(); ws.close();
await server.close(); await server.close();
process.off("SIGUSR1", sigusr1); process.off("SIGUSR1", sigusr1);
vi.useRealTimers();
}); });
}); });
+8 -4
View File
@@ -1,10 +1,11 @@
import type { Tab } from "./navigation"; import type { Tab } from "./navigation";
import { connectGateway } from "./app-gateway"; import { connectGateway } from "./app-gateway";
import { import {
applyStateFromLocation, applySettingsFromUrl,
attachThemeListener, attachThemeListener,
detachThemeListener, detachThemeListener,
inferBasePath, inferBasePath,
syncTabWithLocation,
syncThemeWithSettings, syncThemeWithSettings,
} from "./app-settings"; } from "./app-settings";
import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll"; import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
@@ -32,9 +33,9 @@ type LifecycleHost = {
export function handleConnected(host: LifecycleHost) { export function handleConnected(host: LifecycleHost) {
host.basePath = inferBasePath(); host.basePath = inferBasePath();
applyStateFromLocation( syncTabWithLocation(
host as unknown as Parameters<typeof applyStateFromLocation>[0], host as unknown as Parameters<typeof syncTabWithLocation>[0],
{ replace: true }, true,
); );
syncThemeWithSettings( syncThemeWithSettings(
host as unknown as Parameters<typeof syncThemeWithSettings>[0], host as unknown as Parameters<typeof syncThemeWithSettings>[0],
@@ -43,6 +44,9 @@ export function handleConnected(host: LifecycleHost) {
host as unknown as Parameters<typeof attachThemeListener>[0], host as unknown as Parameters<typeof attachThemeListener>[0],
); );
window.addEventListener("popstate", host.popStateHandler); window.addEventListener("popstate", host.popStateHandler);
applySettingsFromUrl(
host as unknown as Parameters<typeof applySettingsFromUrl>[0],
);
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]); connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]); startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
if (host.tab === "logs") { if (host.tab === "logs") {
+14 -13
View File
@@ -1,5 +1,4 @@
import { html } from "lit"; import { html } from "lit";
import { repeat } from "lit/directives/repeat.js";
import type { AppViewState } from "./app-view-state"; import type { AppViewState } from "./app-view-state";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
@@ -53,18 +52,22 @@ export function renderChatControls(state: AppViewState) {
?disabled=${!state.connected} ?disabled=${!state.connected}
@change=${(e: Event) => { @change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value; const next = (e.target as HTMLSelectElement).value;
state.setSessionKey(next, { state.sessionKey = next;
source: "user", state.chatMessage = "";
resetChat: true, state.chatStream = null;
loadHistory: true, state.chatStreamStartedAt = null;
syncUrl: true, state.chatRunId = null;
replace: true, state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
}); });
void loadChatHistory(state);
}} }}
> >
${repeat( ${sessionOptions.map(
sessionOptions,
(entry) => entry.key,
(entry) => (entry) =>
html`<option value=${entry.key}> html`<option value=${entry.key}>
${entry.displayName ?? entry.key} ${entry.displayName ?? entry.key}
@@ -116,11 +119,9 @@ function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult
const seen = new Set<string>(); const seen = new Set<string>();
const options: Array<{ key: string; displayName?: string }> = []; const options: Array<{ key: string; displayName?: string }> = [];
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
// Add current session key first // Add current session key first
seen.add(sessionKey); seen.add(sessionKey);
options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName }); options.push({ key: sessionKey });
// Add sessions from the result // Add sessions from the result
if (sessions?.sessions) { if (sessions?.sessions) {
+19 -13
View File
@@ -183,14 +183,14 @@ export function renderApp(state: AppViewState) {
onSettingsChange: (next) => state.applySettings(next), onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next), onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => { onSessionKeyChange: (next) => {
state.setSessionKey(next, { state.sessionKey = next;
source: "settings",
resetChat: false,
loadHistory: false,
syncUrl: false,
});
state.chatMessage = ""; state.chatMessage = "";
state.resetToolStream(); state.resetToolStream();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
}, },
onConnect: () => state.connect(), onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(), onRefresh: () => state.loadOverview(),
@@ -366,14 +366,20 @@ export function renderApp(state: AppViewState) {
? renderChat({ ? renderChat({
sessionKey: state.sessionKey, sessionKey: state.sessionKey,
onSessionKeyChange: (next) => { onSessionKeyChange: (next) => {
state.setSessionKey(next, { state.sessionKey = next;
source: "user", state.chatMessage = "";
resetChat: true, state.chatStream = null;
loadHistory: true, state.chatStreamStartedAt = null;
syncUrl: true, state.chatRunId = null;
replace: true, state.chatQueue = [];
clearQueue: true, state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
}); });
void loadChatHistory(state);
}, },
thinkingLevel: state.chatThinkingLevel, thinkingLevel: state.chatThinkingLevel,
loading: state.chatLoading, loading: state.chatLoading,
+33 -124
View File
@@ -1,4 +1,3 @@
import { loadChatHistory } from "./controllers/chat";
import { loadConfig, loadConfigSchema } from "./controllers/config"; import { loadConfig, loadConfigSchema } from "./controllers/config";
import { loadCronJobs, loadCronStatus } from "./controllers/cron"; import { loadCronJobs, loadCronStatus } from "./controllers/cron";
import { loadChannels } from "./controllers/channels"; import { loadChannels } from "./controllers/channels";
@@ -17,7 +16,6 @@ import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import { startLogsPolling, stopLogsPolling } from "./app-polling"; import { startLogsPolling, stopLogsPolling } from "./app-polling";
import { refreshChat } from "./app-chat"; import { refreshChat } from "./app-chat";
import type { ClawdbotApp } from "./app"; import type { ClawdbotApp } from "./app";
import type { ChatQueueItem } from "./ui-types";
type SettingsHost = { type SettingsHost = {
settings: UiSettings; settings: UiSettings;
@@ -36,25 +34,6 @@ type SettingsHost = {
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
}; };
export type SessionKeyOptions = {
source?: "user" | "route" | "settings" | "gateway";
syncUrl?: boolean;
replace?: boolean;
resetChat?: boolean;
loadHistory?: boolean;
clearQueue?: boolean;
};
type SessionKeyHost = SettingsHost & {
chatMessage: string;
chatStream: string | null;
chatStreamStartedAt: number | null;
chatRunId: string | null;
chatQueue: ChatQueueItem[];
resetToolStream: () => void;
resetChatScroll: () => void;
};
export function applySettings(host: SettingsHost, next: UiSettings) { export function applySettings(host: SettingsHost, next: UiSettings) {
const normalized = { const normalized = {
...next, ...next,
@@ -76,85 +55,13 @@ export function setLastActiveSessionKey(host: SettingsHost, next: string) {
applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed }); applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed });
} }
function updateSessionSettings(host: SettingsHost, next: string) { export function applySettingsFromUrl(host: SettingsHost) {
const trimmed = next.trim(); if (!window.location.search) return;
if (!trimmed) return; const params = new URLSearchParams(window.location.search);
if (
host.settings.sessionKey === trimmed &&
host.settings.lastActiveSessionKey === trimmed
) {
return;
}
applySettings(host, {
...host.settings,
sessionKey: trimmed,
lastActiveSessionKey: trimmed,
});
}
export function setSessionKey(
host: SessionKeyHost,
next: string,
options: SessionKeyOptions = {},
) {
const trimmed = next.trim();
if (!trimmed) return;
const shouldResetChat = options.resetChat ?? false;
if (shouldResetChat) {
host.chatMessage = "";
host.chatStream = null;
host.chatStreamStartedAt = null;
host.chatRunId = null;
host.resetToolStream();
host.resetChatScroll();
if (options.clearQueue ?? true) {
host.chatQueue = [];
}
}
if (host.sessionKey !== trimmed) {
host.sessionKey = trimmed;
}
updateSessionSettings(host, trimmed);
if (options.syncUrl ?? host.tab === "chat") {
syncUrlWithTab(host, host.tab, options.replace ?? true);
}
if (options.loadHistory) {
void loadChatHistory(host as unknown as ClawdbotApp);
}
}
type LocationApplyOptions = {
replace: boolean;
};
function buildUrlForTab(host: SettingsHost, tab: Tab, baseUrl?: URL) {
const url = baseUrl ? new URL(baseUrl.toString()) : new URL(window.location.href);
const targetPath = normalizePath(pathForTab(tab, host.basePath));
url.pathname = targetPath;
if (tab === "chat" && host.sessionKey) {
url.searchParams.set("session", host.sessionKey);
} else {
url.searchParams.delete("session");
}
return url;
}
export function applyStateFromLocation(
host: SessionKeyHost,
options: LocationApplyOptions,
) {
if (typeof window === "undefined") return;
const currentHref = window.location.href;
const url = new URL(currentHref);
const params = url.searchParams;
const tokenRaw = params.get("token"); const tokenRaw = params.get("token");
const passwordRaw = params.get("password"); const passwordRaw = params.get("password");
const sessionRaw = params.get("session"); const sessionRaw = params.get("session");
let shouldCleanUrl = false;
if (tokenRaw != null) { if (tokenRaw != null) {
const token = tokenRaw.trim(); const token = tokenRaw.trim();
@@ -162,6 +69,7 @@ export function applyStateFromLocation(
applySettings(host, { ...host.settings, token }); applySettings(host, { ...host.settings, token });
} }
params.delete("token"); params.delete("token");
shouldCleanUrl = true;
} }
if (passwordRaw != null) { if (passwordRaw != null) {
@@ -170,34 +78,22 @@ export function applyStateFromLocation(
(host as { password: string }).password = password; (host as { password: string }).password = password;
} }
params.delete("password"); params.delete("password");
shouldCleanUrl = true;
} }
const resolved = tabFromPath(url.pathname, host.basePath) ?? "chat"; if (sessionRaw != null) {
const session = sessionRaw?.trim() ?? ""; const session = sessionRaw.trim();
if (resolved === "chat") {
if (session) { if (session) {
setSessionKey(host, session, { host.sessionKey = session;
source: "route",
syncUrl: false,
resetChat: false,
loadHistory: false,
});
} else if (sessionRaw != null) {
params.delete("session");
} }
} else if (sessionRaw != null) {
params.delete("session"); params.delete("session");
shouldCleanUrl = true;
} }
setTabFromRoute(host, resolved); if (!shouldCleanUrl) return;
const url = new URL(window.location.href);
const targetUrl = buildUrlForTab(host, resolved, url).toString(); url.search = params.toString();
if (targetUrl === currentHref) return; window.history.replaceState({}, "", url.toString());
if (options.replace) {
window.history.replaceState({}, "", targetUrl);
} else {
window.history.pushState({}, "", targetUrl);
}
} }
export function setTab(host: SettingsHost, next: Tab) { export function setTab(host: SettingsHost, next: Tab) {
@@ -318,8 +214,18 @@ export function detachThemeListener(host: SettingsHost) {
host.themeMediaHandler = null; host.themeMediaHandler = null;
} }
export function onPopState(host: SessionKeyHost) { export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
applyStateFromLocation(host, { replace: true }); if (typeof window === "undefined") return;
const resolved = tabFromPath(window.location.pathname, host.basePath) ?? "chat";
setTabFromRoute(host, resolved);
syncUrlWithTab(host, resolved, replace);
}
export function onPopState(host: SettingsHost) {
if (typeof window === "undefined") return;
const resolved = tabFromPath(window.location.pathname, host.basePath);
if (!resolved) return;
setTabFromRoute(host, resolved);
} }
export function setTabFromRoute(host: SettingsHost, next: Tab) { export function setTabFromRoute(host: SettingsHost, next: Tab) {
@@ -333,12 +239,15 @@ export function setTabFromRoute(host: SettingsHost, next: Tab) {
export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) { export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const targetUrl = buildUrlForTab(host, tab).toString(); const targetPath = normalizePath(pathForTab(tab, host.basePath));
if (targetUrl === window.location.href) return; const currentPath = normalizePath(window.location.pathname);
if (currentPath === targetPath) return;
const url = new URL(window.location.href);
url.pathname = targetPath;
if (replace) { if (replace) {
window.history.replaceState({}, "", targetUrl); window.history.replaceState({}, "", url.toString());
} else { } else {
window.history.pushState({}, "", targetUrl); window.history.pushState({}, "", url.toString());
} }
} }
+1 -2
View File
@@ -1,6 +1,5 @@
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import type { Tab } from "./navigation"; import type { Tab } from "./navigation";
import type { SessionKeyOptions } from "./app-settings";
import type { UiSettings } from "./storage"; import type { UiSettings } from "./storage";
import type { ThemeMode } from "./theme"; import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition"; import type { ThemeTransitionContext } from "./theme-transition";
@@ -161,7 +160,7 @@ export type AppViewState = {
handleDebugCall: () => Promise<void>; handleDebugCall: () => Promise<void>;
handleRunUpdate: () => Promise<void>; handleRunUpdate: () => Promise<void>;
setPassword: (next: string) => void; setPassword: (next: string) => void;
setSessionKey: (next: string, options?: SessionKeyOptions) => void; setSessionKey: (next: string) => void;
setChatMessage: (next: string) => void; setChatMessage: (next: string) => void;
handleChatSend: () => Promise<void>; handleChatSend: () => Promise<void>;
handleChatAbort: () => Promise<void>; handleChatAbort: () => Promise<void>;
-10
View File
@@ -50,10 +50,8 @@ import {
applySettings as applySettingsInternal, applySettings as applySettingsInternal,
loadCron as loadCronInternal, loadCron as loadCronInternal,
loadOverview as loadOverviewInternal, loadOverview as loadOverviewInternal,
setSessionKey as setSessionKeyInternal,
setTab as setTabInternal, setTab as setTabInternal,
setTheme as setThemeInternal, setTheme as setThemeInternal,
type SessionKeyOptions,
onPopState as onPopStateInternal, onPopState as onPopStateInternal,
} from "./app-settings"; } from "./app-settings";
import { import {
@@ -299,14 +297,6 @@ export class ClawdbotApp extends LitElement {
setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next); setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next);
} }
setSessionKey(next: string, options?: SessionKeyOptions) {
setSessionKeyInternal(
this as unknown as Parameters<typeof setSessionKeyInternal>[0],
next,
options,
);
}
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) { setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal( setThemeInternal(
this as unknown as Parameters<typeof setThemeInternal>[0], this as unknown as Parameters<typeof setThemeInternal>[0],
-29
View File
@@ -167,35 +167,6 @@ describe("control UI routing", () => {
expect(window.location.search).toBe(""); expect(window.location.search).toBe("");
}); });
it("hydrates session from chat URL params", async () => {
const app = mountApp("/chat?session=agent:main");
await app.updateComplete;
expect(app.sessionKey).toBe("agent:main");
expect(window.location.pathname).toBe("/chat");
expect(new URLSearchParams(window.location.search).get("session")).toBe(
"agent:main",
);
});
it("adds session param for chat routes when missing", async () => {
const app = mountApp("/chat");
await app.updateComplete;
expect(app.sessionKey).toBe("main");
expect(window.location.pathname).toBe("/chat");
expect(new URLSearchParams(window.location.search).get("session")).toBe("main");
});
it("strips session params from non-chat routes", async () => {
const app = mountApp("/sessions?session=agent:main");
await app.updateComplete;
expect(app.tab).toBe("sessions");
expect(window.location.pathname).toBe("/sessions");
expect(new URLSearchParams(window.location.search).get("session")).toBeNull();
});
it("hydrates token from URL params even when settings already set", async () => { it("hydrates token from URL params even when settings already set", async () => {
localStorage.setItem( localStorage.setItem(
"clawdbot.control.settings.v1", "clawdbot.control.settings.v1",