feat(web): add MCP settings and control handoff UI

Add the MCP settings view and frontend API bindings for configuring remote-control access.

Expose AI-control ownership state in PicoClaw, lock conflicting keyboard input paths, and preserve chat state when control is released.
This commit is contained in:
SiYue-ZO
2026-07-20 18:01:30 +08:00
committed by Guoguo
parent 64ce12cc0d
commit 6b98f5415f
30 changed files with 2289 additions and 355 deletions

20
web/src/api/mcp.ts Normal file
View File

@@ -0,0 +1,20 @@
import { http } from '@/lib/http.ts';
export type MCPConfig = {
enabled: boolean;
apiKey: string;
controlMode: 'off' | 'mcp' | 'picoclaw';
transitioning: boolean;
};
export function getMCPConfig() {
return http.get('/api/mcp/config');
}
export function setMCPEnabled(enabled: boolean) {
return http.post('/api/mcp/config', { enabled });
}
export function regenerateMCPAPIKey() {
return http.post('/api/mcp/key/regenerate');
}

View File

@@ -71,6 +71,18 @@ export function stopRuntime() {
return http.post('/api/picoclaw/runtime/stop');
}
export function getAIControlStatus() {
return http.get('/api/ai/control/status');
}
export function setAIControlMode(mode: 'off' | 'mcp' | 'picoclaw') {
return http.request({
method: 'put',
url: '/api/ai/control/mode',
data: { mode }
});
}
export function installRuntime() {
return http.post('/api/picoclaw/runtime/install');
}

View File

@@ -0,0 +1,74 @@
import { atom } from 'jotai';
export type AIControlMode = 'off' | 'mcp' | 'picoclaw';
export type AIControlStatus = {
mode: AIControlMode;
transitioning: boolean;
canControlPicoclaw: boolean;
changedAt?: string;
lastError?: string;
source?: string;
};
export const aiControlStatusAtom = atom<AIControlStatus | null>(null);
export function normalizeAIControlStatus(value: unknown, source?: string): AIControlStatus | null {
const record = asRecord(value);
if (!record) {
return null;
}
const control = asRecord(record.control);
const capabilities = asRecord(record.capabilities);
const mode = parseAIControlMode(
control?.mode ?? record.mode ?? record.controlMode ?? record.control_mode
);
if (!mode) {
return null;
}
const transitioning = readBoolean(control?.transitioning ?? record.transitioning) ?? false;
const explicitCanControl = readBoolean(
control?.can_control ?? record.can_control ?? record.canControlPicoclaw
);
const deviceWrite = readBoolean(capabilities?.device_write);
const changedAt = readString(control?.changed_at ?? control?.changedAt ?? record.changed_at);
const lastError = readString(control?.last_error ?? control?.lastError ?? record.last_error);
const payloadSource = readString(control?.source ?? record.source);
return {
mode,
transitioning,
canControlPicoclaw:
explicitCanControl ?? deviceWrite ?? (mode === 'picoclaw' && transitioning !== true),
changedAt,
lastError,
source: source ?? payloadSource
};
}
function parseAIControlMode(value: unknown): AIControlMode | null {
if (value === 'off' || value === 'mcp' || value === 'picoclaw') {
return value;
}
return null;
}
function readBoolean(value: unknown) {
return typeof value === 'boolean' ? value : undefined;
}
function readString(value: unknown) {
if (typeof value !== 'string' || value.trim() === '') {
return undefined;
}
return value;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}

View File

@@ -1,7 +1,26 @@
import { atom } from 'jotai';
export type KeyboardLockSource = string;
type KeyboardLockAction = {
source: KeyboardLockSource;
locked: boolean;
};
const keyboardLocksAtom = atom<Set<KeyboardLockSource>>(new Set<KeyboardLockSource>());
// is the keyboard enabled (Disable keyboard events when input is required)
export const isKeyboardEnableAtom = atom(true);
export const isKeyboardEnableAtom = atom((get) => get(keyboardLocksAtom).size === 0);
export const keyboardLockAtom = atom(null, (get, set, action: KeyboardLockAction) => {
const locks = new Set(get(keyboardLocksAtom));
if (action.locked) {
locks.add(action.source);
} else {
locks.delete(action.source);
}
set(keyboardLocksAtom, locks);
});
// is the virtual keyboard opened
export const isKeyboardOpenAtom = atom(false);

View File

@@ -21,6 +21,29 @@ export type PicoclawRuntimeStatus = {
last_error?: string;
checked_at?: string;
current_session?: string;
restoring?: boolean;
runtime_intent?: {
desired_running: boolean;
updated_at?: string;
updated_by?: string;
last_started_at?: string;
last_stopped_at?: string;
last_error?: string;
};
control_mode: 'off' | 'mcp' | 'picoclaw';
transitioning?: boolean;
control?: {
mode: 'off' | 'mcp' | 'picoclaw';
transitioning: boolean;
can_control: boolean;
last_error?: string;
changed_at?: string;
};
capabilities?: {
chat: boolean;
read_only_tools: boolean;
device_write: boolean;
};
};
export type PicoclawRuntimeStartResult = {

View File

@@ -123,9 +123,9 @@ export class MouseReportAbsolute {
return this.buildReport(lastX, lastY, 0);
}
reset(): Uint8Array {
reset(x: number = 0, y: number = 0): Uint8Array {
this.buttons = 0;
return this.buildReport(0, 0, 0);
return this.buildReport(x, y, 0);
}
private clamp(value: number, min: number, max: number): number {

View File

@@ -19,6 +19,23 @@ export type GatewayRuntimeStatus = {
last_error?: string;
checked_at?: string;
current_session?: string;
control_mode: 'off' | 'mcp' | 'picoclaw';
transitioning?: boolean;
control?: GatewayControlStatus;
capabilities?: {
chat: boolean;
read_only_tools: boolean;
device_write: boolean;
};
};
export type GatewayControlStatus = {
mode: 'off' | 'mcp' | 'picoclaw';
transitioning: boolean;
can_control: boolean;
last_error?: string;
changed_at?: string;
source?: string;
};
export type GatewayAssistantMessage = {
@@ -62,6 +79,7 @@ type GatewayEventMap = {
observation: GatewayObservation;
error: GatewayError;
close: GatewayClose;
control_mode_changed: GatewayControlStatus;
};
type EventName = keyof GatewayEventMap;
@@ -84,7 +102,9 @@ const CLOSE_ERRORS: Record<number, string> = {
4002: 'RUNTIME_UNAVAILABLE',
4003: 'AUTH_FAILED',
4004: 'AI_TAKEN_OVER',
4005: 'UPSTREAM_CLOSED'
4005: 'UPSTREAM_CLOSED',
4006: 'AI_MODE_CONFLICT',
4007: 'RUNTIME_STOPPED'
};
export function generateUUIDv4() {
@@ -165,7 +185,7 @@ class PicoClawGateway {
}
this.ws = null;
if (event.code !== 1000) {
if (event.code !== 1000 && event.code !== 4006 && event.code !== 4007) {
const code = CLOSE_ERRORS[event.code] || 'UNKNOWN';
if (code !== 'UNKNOWN') {
this.emit('error', {
@@ -180,12 +200,16 @@ class PicoClawGateway {
reason: event.reason
});
this.setTransportState(event.code === 1000 ? 'disconnected' : 'error');
this.setTransportState(
event.code === 1000 || event.code === 4006 || event.code === 4007
? 'disconnected'
: 'error'
);
this.setRunState('idle');
if (!this.explicitClose) {
reject(new Error(event.reason || 'gateway closed'));
if (this.autoReconnect) {
if (this.autoReconnect && event.code !== 4006 && event.code !== 4007) {
this.scheduleReconnect();
}
}
@@ -350,6 +374,21 @@ class PicoClawGateway {
});
return;
}
if (type === 'control.mode_changed') {
const payload = (message.payload || {}) as Record<string, unknown>;
const mode = String(payload.mode || 'off');
if (mode === 'off' || mode === 'mcp' || mode === 'picoclaw') {
this.emit('control_mode_changed', {
mode,
transitioning: payload.transitioning === true,
can_control: payload.can_control === true,
last_error: typeof payload.last_error === 'string' ? payload.last_error : undefined,
changed_at: typeof payload.changed_at === 'string' ? payload.changed_at : undefined,
source: typeof payload.source === 'string' ? payload.source : undefined
});
}
return;
}
if (type === 'message.create' || type === 'message.update') {
this.setRunState('idle');
this.emit('assistant_message', {

View File

@@ -6,20 +6,15 @@ import { useSetAtom } from 'jotai';
import { DownloadIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
cancelDownloadImage,
downloadImage,
imageEnabled,
statusImage
} from '@/api/download.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { cancelDownloadImage, downloadImage, imageEnabled, statusImage } from '@/api/download.ts';
import { keyboardLockAtom } from '@/jotai/keyboard.ts';
import { MenuItem } from '@/components/menu-item.tsx';
const imageUpdatedEvent = 'nanokvm:image-updated';
export const DownloadImage = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setKeyboardLock = useSetAtom(keyboardLockAtom);
const [input, setInput] = useState('');
const [sha256sum, setSha256sum] = useState('');
@@ -58,10 +53,10 @@ export const DownloadImage = () => {
if (open) {
checkDiskEnabled();
startStatusPolling();
setIsKeyboardEnable(false);
setKeyboardLock({ source: 'download-popover', locked: true });
setPopoverKey((prevKey) => prevKey + 1); // Force re-render
} else {
setIsKeyboardEnable(true);
setKeyboardLock({ source: 'download-popover', locked: false });
// Keep monitoring an active remote download after the popover closes so
// completion can still refresh an already-open image list.
@@ -243,7 +238,7 @@ export const DownloadImage = () => {
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] ?? null;
if (!file || !file.name.toLowerCase().endsWith(".iso")) {
if (!file || !file.name.toLowerCase().endsWith('.iso')) {
setStatus('failed');
setLog(t('download.NoISO'));
return;
@@ -259,7 +254,7 @@ export const DownloadImage = () => {
function upload(file: File | null) {
if (!file) return;
if (!file || !file.name.toLowerCase().endsWith(".iso")) {
if (!file || !file.name.toLowerCase().endsWith('.iso')) {
setStatus('failed');
setLog(t('download.NoISO'));
return;
@@ -275,20 +270,19 @@ export const DownloadImage = () => {
setLog('Downloading: ' + file.name);
const formData = new FormData();
formData.append("file", file);
formData.append('file', file);
fetch("/api/download/file", {
method: "POST",
fetch('/api/download/file', {
method: 'POST',
headers: {
'X-SHA256-Sum': checksum
},
body: formData,
body: formData
})
.then(async (response) => {
const rsp = await response.json();
if (!response.ok || rsp.code !== 0) {
const message =
rsp.msg === 'sha256 mismatch' ? t('download.checksumFailed') : rsp.msg;
const message = rsp.msg === 'sha256 mismatch' ? t('download.checksumFailed') : rsp.msg;
throw new Error(message || t('download.failed'));
}
@@ -303,7 +297,6 @@ export const DownloadImage = () => {
});
startStatusPolling();
}
const content = (
@@ -337,9 +330,7 @@ export const DownloadImage = () => {
? cancelDownload()
: download(input)
}
disabled={
isCancelling || (status === 'in_progress' && !isRemoteDownloading)
}
disabled={isCancelling || (status === 'in_progress' && !isRemoteDownloading)}
>
{isRemoteDownloading && status === 'in_progress'
? t('download.cancel')
@@ -368,35 +359,35 @@ export const DownloadImage = () => {
? 'cursor-not-allowed bg-neutral-700 opacity-50'
: 'cursor-pointer hover:bg-neutral-500'
)}
onDrop={(e) => {
if (status === "in_progress") return; // deaktiviert
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0] ?? null;
if (!file || !file.name.toLowerCase().endsWith(".iso")) {
setStatus('failed');
setLog(t('download.NoISO'));
return;
}
setStatus('idle');
setLog('');
setSelectedFile(file);
}}
onDragOver={(e) => {
if (status === "in_progress") return; // deaktiviert
e.preventDefault();
setIsDragging(true); // Datei wird über den Bereich gezogen
}}
onDragLeave={(e) => {
if (status === "in_progress") return; // deaktiviert
e.preventDefault();
setIsDragging(false); // Maus verlässt Bereich
}}
onClick={() => {
if (status === "in_progress") return; // deaktiviert
document.getElementById("file-upload")?.click()
}}
>
onDrop={(e) => {
if (status === 'in_progress') return; // deaktiviert
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0] ?? null;
if (!file || !file.name.toLowerCase().endsWith('.iso')) {
setStatus('failed');
setLog(t('download.NoISO'));
return;
}
setStatus('idle');
setLog('');
setSelectedFile(file);
}}
onDragOver={(e) => {
if (status === 'in_progress') return; // deaktiviert
e.preventDefault();
setIsDragging(true); // Datei wird über den Bereich gezogen
}}
onDragLeave={(e) => {
if (status === 'in_progress') return; // deaktiviert
e.preventDefault();
setIsDragging(false); // Maus verlässt Bereich
}}
onClick={() => {
if (status === 'in_progress') return; // deaktiviert
document.getElementById('file-upload')?.click();
}}
>
<span className="w-full truncate px-2 text-center text-sm text-neutral-100">
{selectedFile ? selectedFile.name : t('download.uploadbox')}
</span>

View File

@@ -13,12 +13,12 @@ import {
import { useTranslation } from 'react-i18next';
import * as api from '@/api/hid.ts';
import { isKeyboardEnableAtom, leaderKeyAtom } from '@/jotai/keyboard.ts';
import { keyboardLockAtom, leaderKeyAtom } from '@/jotai/keyboard.ts';
export const LeaderKey = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setKeyboardLock = useSetAtom(keyboardLockAtom);
const [leaderKey, setLeaderKey] = useAtom(leaderKeyAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -64,7 +64,7 @@ export const LeaderKey = () => {
}, [isFocused]);
function openModal() {
setIsKeyboardEnable(false);
setKeyboardLock({ source: 'leader-key-recorder', locked: true });
setIsLeaderKeyEnable(!!leaderKey);
setTempLeaderKey(leaderKey);
setIsDocCollapsed(true);
@@ -72,7 +72,7 @@ export const LeaderKey = () => {
}
function closeModal() {
setIsKeyboardEnable(true);
setKeyboardLock({ source: 'leader-key-recorder', locked: false });
setIsModalOpen(false);
}

View File

@@ -7,13 +7,13 @@ import { ClipboardIcon, ClipboardPasteIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { paste } from '@/api/hid';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { keyboardLockAtom } from '@/jotai/keyboard.ts';
type InputStatus = '' | 'error';
export const Paste = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setKeyboardLock = useSetAtom(keyboardLockAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
@@ -63,16 +63,43 @@ export const Paste = () => {
setIsReadingClipboard(false);
}
}
// Extended RU → EN translation including punctuation
function translateRuToEnWithPunctuation(value: string): string {
const letterMap: Record<string, string> = {
'ё': '`',
'й': 'q', 'ц': 'w', 'у': 'e', 'к': 'r', 'е': 't', 'н': 'y', 'г': 'u', 'ш': 'i',
'щ': 'o', 'з': 'p', 'х': '[', 'ъ': ']',
'ф': 'a', 'ы': 's', 'в': 'd', 'а': 'f', 'п': 'g', 'р': 'h', 'о': 'j', 'л': 'k',
'д': 'l', 'ж': ';', 'э': '\'',
'я': 'z', 'ч': 'x', 'с': 'c', 'м': 'v', 'и': 'b', 'т': 'n', 'ь': 'm', 'б': ',', 'ю': '.',
ё: '`',
й: 'q',
ц: 'w',
у: 'e',
к: 'r',
е: 't',
н: 'y',
г: 'u',
ш: 'i',
щ: 'o',
з: 'p',
х: '[',
ъ: ']',
ф: 'a',
ы: 's',
в: 'd',
а: 'f',
п: 'g',
р: 'h',
о: 'j',
л: 'k',
д: 'l',
ж: ';',
э: "'",
я: 'z',
ч: 'x',
с: 'c',
м: 'v',
и: 'b',
т: 'n',
ь: 'm',
б: ',',
ю: '.'
};
const punctuationMap: Record<string, string> = {
@@ -81,23 +108,25 @@ export const Paste = () => {
';': '$',
':': '^',
'?': '&',
'Ё': '~',
Ё: '~',
'/': '|',
'.': '/',
',': '?',
',': '?'
};
return Array.from(value).map((ch) => {
const lower = ch.toLowerCase();
if (letterMap[lower]) {
const translated = letterMap[lower];
return ch === lower ? translated : translated.toUpperCase();
}
if (punctuationMap[ch]) {
return punctuationMap[ch];
}
return ch;
}).join('');
return Array.from(value)
.map((ch) => {
const lower = ch.toLowerCase();
if (letterMap[lower]) {
const translated = letterMap[lower];
return ch === lower ? translated : translated.toUpperCase();
}
if (punctuationMap[ch]) {
return punctuationMap[ch];
}
return ch;
})
.join('');
}
function submit() {
@@ -128,7 +157,7 @@ export const Paste = () => {
inputRef.current?.focus();
}
setIsKeyboardEnable(!open);
setKeyboardLock({ source: 'paste-modal', locked: open });
}
function isValidForLanguage(value: string, selectedLangue: string) {
@@ -142,11 +171,14 @@ export const Paste = () => {
// For Russian language, allow Cyrillic letters and special characters
// that can be typed on Russian keyboard (but not English letters)
if (
(code >= 0x0410 && code <= 0x042F) || // (А-Я)
(code >= 0x0430 && code <= 0x044F) || // (а-я)
(code >= 0x0410 && code <= 0x042f) || // (А-Я)
(code >= 0x0430 && code <= 0x044f) || // (а-я)
code === 0x0401 || // (Ё)
code === 0x0451 || // (ё)
(code >= 0x20 && code <= 0x7E && !(code >= 0x41 && code <= 0x5A) && !(code >= 0x61 && code <= 0x7A)) // Special chars, digits, space, but not English letters
(code >= 0x20 &&
code <= 0x7e &&
!(code >= 0x41 && code <= 0x5a) &&
!(code >= 0x61 && code <= 0x7a)) // Special chars, digits, space, but not English letters
) {
continue;
}
@@ -154,12 +186,12 @@ export const Paste = () => {
} else if (isFrench) {
// For French, allow ASCII and Latin-1/Extended accented characters
// (é è ê ë à â ù û ç î ï ô œ æ ° µ £ § ¨ etc.)
if (code <= 0x7F) continue;
if (code >= 0x00A0 && code <= 0x017E) continue;
if (code <= 0x7f) continue;
if (code >= 0x00a0 && code <= 0x017e) continue;
return false;
} else {
// For English/German, only allow ASCII
if (code <= 0x7F) continue;
if (code <= 0x7f) continue;
return false;
}
}

View File

@@ -5,7 +5,7 @@ import { KeyboardIcon, Trash2Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isModifier } from '@/lib/keymap.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { keyboardLockAtom } from '@/jotai/keyboard.ts';
import { Kbd, KbdGroup } from '@/components/ui/kbd.tsx';
import { ScrollArea } from '@/components/ui/scroll-area.tsx';
@@ -60,7 +60,7 @@ export const Recorder = ({
setIsRecording
}: RecorderProps) => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setKeyboardLock = useSetAtom(keyboardLockAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false);
@@ -70,7 +70,7 @@ export const Recorder = ({
const recordedKeysRef = useRef<KeyInfo[]>([]);
useEffect(() => {
setIsKeyboardEnable(!isModalOpen);
setKeyboardLock({ source: 'shortcut-recorder', locked: isModalOpen });
setIsRecording(isModalOpen);
if (!isModalOpen) return;
@@ -81,8 +81,9 @@ export const Recorder = ({
return () => {
clearTimeout(timer);
setKeyboardLock({ source: 'shortcut-recorder', locked: false });
};
}, [isModalOpen]);
}, [isModalOpen, setIsRecording, setKeyboardLock]);
useEffect(() => {
if (!isFocused) return;

View File

@@ -7,7 +7,7 @@ import { useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/autostart.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { keyboardLockAtom } from '@/jotai/keyboard.ts';
export const Autostart = () => {
interface AutostartItem {
@@ -16,7 +16,7 @@ export const Autostart = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setKeyboardLock = useSetAtom(keyboardLockAtom);
const [isEditAutostartOpen, setIsEditAutostartOpen] = useState(false);
const [isManageAutostartOpen, setIsManageAutostartOpen] = useState(false);
@@ -52,13 +52,13 @@ export const Autostart = () => {
];
useEffect(() => {
setIsKeyboardEnable(false);
setKeyboardLock({ source: 'autostart-settings', locked: true });
getAutostart();
return () => {
setIsKeyboardEnable(true);
setKeyboardLock({ source: 'autostart-settings', locked: false });
};
}, []);
}, [setKeyboardLock]);
function getAutostart() {
api.getAutostart().then((rsp) => {
@@ -166,8 +166,8 @@ export const Autostart = () => {
footer=""
onCancel={() => setIsManageAutostartOpen(false)}
title={t('settings.device.autostart.title')}
>
<div className="flex justify-end">
>
<div className="flex justify-end">
<Button
type="text"
onClick={() => {

View File

@@ -4,6 +4,7 @@ import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import {
BadgeInfoIcon,
BotIcon,
CircleArrowUpIcon,
NetworkIcon,
PaletteIcon,
@@ -16,7 +17,7 @@ import semver from 'semver';
import * as api from '@/api/application.ts';
import * as ls from '@/lib/localstorage.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { keyboardLockAtom } from '@/jotai/keyboard.ts';
import { submenuOpenCountAtom } from '@/jotai/settings.ts';
import { Tailscale as TailscaleIcon } from '@/components/icons/tailscale';
import { ScrollArea } from '@/components/ui/scroll-area';
@@ -25,6 +26,7 @@ import { About } from './about';
import { Account } from './account';
import { Appearance } from './appearance';
import { Device } from './device';
import { MCP } from './mcp';
import { Network } from './network';
import { Tailscale } from './tailscale';
import { Update } from './update';
@@ -38,7 +40,7 @@ export const Settings = () => {
const scrollViewportRef = useRef<HTMLDivElement>(null);
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setKeyboardLock = useSetAtom(keyboardLockAtom);
const setSubmenuOpenCount = useSetAtom(submenuOpenCountAtom);
const tabs = [
@@ -46,6 +48,7 @@ export const Settings = () => {
{ id: 'appearance', icon: <PaletteIcon size={16} />, component: <Appearance /> },
{ id: 'device', icon: <SmartphoneIcon size={16} />, component: <Device /> },
{ id: 'network', icon: <NetworkIcon size={16} />, component: <Network /> },
{ id: 'mcp', icon: <BotIcon size={16} />, component: <MCP /> },
{
id: 'tailscale',
icon: <TailscaleIcon />,
@@ -100,7 +103,7 @@ export const Settings = () => {
function openModal() {
setIsModalOpen(true);
setIsKeyboardEnable(false);
setKeyboardLock({ source: 'settings-modal', locked: true });
setSubmenuOpenCount((count) => count + 1);
}
@@ -109,7 +112,7 @@ export const Settings = () => {
return;
}
setIsKeyboardEnable(true);
setKeyboardLock({ source: 'settings-modal', locked: false });
setIsModalOpen(false);
setCurrentTab('about');
setSubmenuOpenCount((count) => Math.max(0, count - 1));

View File

@@ -0,0 +1,320 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { Alert, Button, Divider, message, Modal, Switch } from 'antd';
import { useSetAtom } from 'jotai';
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon, RefreshCcwIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/mcp.ts';
import type { MCPConfig } from '@/api/mcp.ts';
import { getBaseUrl } from '@/lib/service.ts';
import { aiControlStatusAtom, normalizeAIControlStatus } from '@/jotai/ai-control.ts';
function maskKey(key: string) {
if (!key) return '-';
if (key.length <= 16) return `${key.slice(0, 4)}...${key.slice(-4)}`;
return `${key.slice(0, 12)}...${key.slice(-6)}`;
}
function responseMessage(rsp: { msg?: string; message?: string }) {
return rsp.msg || rsp.message || '';
}
async function writeClipboardText(text: string) {
if (window.isSecureContext === true && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fall through to the legacy path for HTTP deployments and browser quirks.
}
}
const textArea = document.createElement('textarea');
const selection = document.getSelection();
const selectedRange = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
textArea.value = text;
textArea.setAttribute('readonly', '');
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
textArea.setSelectionRange(0, text.length);
try {
if (!document.execCommand('copy')) {
throw new Error('copy command failed');
}
} finally {
document.body.removeChild(textArea);
if (selectedRange && selection) {
selection.removeAllRanges();
selection.addRange(selectedRange);
}
}
}
export const MCP = () => {
const { t } = useTranslation();
const [modal, contextHolder] = Modal.useModal();
const setAIControlStatus = useSetAtom(aiControlStatusAtom);
const [config, setConfig] = useState<MCPConfig>({
enabled: false,
apiKey: '',
controlMode: 'picoclaw',
transitioning: false
});
const [isLoading, setIsLoading] = useState(false);
const [isKeyVisible, setIsKeyVisible] = useState(false);
const [isEndpointCopied, setIsEndpointCopied] = useState(false);
const [isKeyCopied, setIsKeyCopied] = useState(false);
const isLoadingRef = useRef(false);
const silentRefreshRef = useRef(false);
const actionVersionRef = useRef(0);
const endpoint = useMemo(() => `${getBaseUrl('http')}/api/mcp`, []);
const displayKey = isKeyVisible ? config.apiKey || '-' : maskKey(config.apiKey);
const syncConfig = useCallback(
(nextConfig: MCPConfig) => {
setConfig(nextConfig);
const nextControlStatus = normalizeAIControlStatus(nextConfig, 'mcp_config');
if (nextControlStatus) {
setAIControlStatus(nextControlStatus);
}
},
[setAIControlStatus]
);
const updateLoading = useCallback((loading: boolean) => {
isLoadingRef.current = loading;
setIsLoading(loading);
}, []);
const getConfig = useCallback(
(silent = false) => {
if (silent) {
if (isLoadingRef.current || silentRefreshRef.current) return;
silentRefreshRef.current = true;
} else {
updateLoading(true);
}
const actionVersion = actionVersionRef.current;
api
.getMCPConfig()
.then((rsp) => {
if (rsp.code !== 0) {
if (!silent) message.error(t('settings.mcp.failed'));
return;
}
if (silent && actionVersion !== actionVersionRef.current) return;
syncConfig(rsp.data);
if (!rsp.data.enabled) setIsKeyVisible(false);
})
.catch(() => {
if (!silent) message.error(t('settings.mcp.failed'));
})
.finally(() => {
if (silent) silentRefreshRef.current = false;
else updateLoading(false);
});
},
[syncConfig, t, updateLoading]
);
useEffect(() => {
getConfig();
const timer = window.setInterval(() => getConfig(true), 3000);
return () => window.clearInterval(timer);
}, [getConfig]);
function updateEnabled(enabled: boolean) {
if (isLoading) return;
actionVersionRef.current += 1;
updateLoading(true);
api
.setMCPEnabled(enabled)
.then((rsp) => {
if (rsp.code !== 0) {
message.error(responseMessage(rsp) || t('settings.mcp.failed'));
return;
}
syncConfig(rsp.data);
if (!enabled) setIsKeyVisible(false);
})
.catch(() => message.error(t('settings.mcp.failed')))
.finally(() => updateLoading(false));
}
function setEnabled(enabled: boolean) {
if (!enabled) {
updateEnabled(false);
return;
}
modal.confirm({
title: t('settings.mcp.enableConfirmTitle'),
content: (
<span className="text-sm text-neutral-400">{t('settings.mcp.enableConfirmDesc')}</span>
),
okText: t('settings.mcp.okBtn'),
cancelText: t('settings.mcp.cancelBtn'),
onOk: () => updateEnabled(true)
});
}
async function copyText(text: string, type: 'endpoint' | 'key') {
if (!text) return;
try {
await writeClipboardText(text);
const setCopied = type === 'endpoint' ? setIsEndpointCopied : setIsKeyCopied;
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
message.error(
t('settings.mcp.copyFailed', {
defaultValue: 'Copy failed. Copy manually.'
})
);
}
}
function regenerateKey() {
if (isLoading || !config.enabled) return;
modal.confirm({
title: t('settings.mcp.regenerateConfirmTitle'),
content: (
<span className="text-sm text-neutral-400">{t('settings.mcp.regenerateConfirmDesc')}</span>
),
okText: t('settings.mcp.okBtn'),
cancelText: t('settings.mcp.cancelBtn'),
onOk: async () => {
actionVersionRef.current += 1;
updateLoading(true);
try {
const rsp = await api.regenerateMCPAPIKey();
if (rsp.code !== 0) {
message.error(responseMessage(rsp) || t('settings.mcp.failed'));
return;
}
syncConfig(rsp.data);
setIsKeyVisible(false);
} catch {
message.error(t('settings.mcp.failed'));
} finally {
updateLoading(false);
}
}
});
}
return (
<>
{contextHolder}
<div className="text-base">{t('settings.mcp.title')}</div>
<Divider className="opacity-50" />
<div className="flex flex-col space-y-6">
<Alert type="warning" showIcon message={t('settings.mcp.securityWarning')} />
<div className="flex items-center justify-between">
<div className="flex flex-col space-y-1 pr-4">
<span className="text-sm font-medium">{t('settings.mcp.service')}</span>
<span className="text-xs text-neutral-500">{t('settings.mcp.serviceDesc')}</span>
</div>
<Switch
checked={config.enabled}
loading={isLoading || config.transitioning}
disabled={config.transitioning}
onChange={(enabled) => setEnabled(enabled)}
/>
</div>
{config.enabled && (
<div className="animate-in fade-in slide-in-from-top-2 duration-300">
<div className="flex flex-col overflow-hidden rounded-xl border border-neutral-700/50 bg-neutral-800/40 shadow-sm">
<CredentialRow
label={t('settings.mcp.endpoint')}
value={endpoint}
copied={isEndpointCopied}
onCopy={() => copyText(endpoint, 'endpoint')}
/>
<div className="border-t border-neutral-800" />
<CredentialRow
label={t('settings.mcp.apiKey')}
value={displayKey}
copied={isKeyCopied}
onCopy={() => copyText(config.apiKey, 'key')}
disabled={!config.apiKey}
actions={
<>
<Button
type="text"
size="small"
className="text-neutral-400 hover:text-white"
icon={isKeyVisible ? <EyeOffIcon size={15} /> : <EyeIcon size={15} />}
disabled={!config.apiKey}
onClick={() => setIsKeyVisible((visible) => !visible)}
/>
<Button
type="text"
size="small"
className="text-neutral-400 hover:text-white"
loading={isLoading}
icon={<RefreshCcwIcon size={15} />}
onClick={regenerateKey}
/>
</>
}
/>
</div>
</div>
)}
</div>
</>
);
};
type CredentialRowProps = {
label: string;
value: string;
copied: boolean;
onCopy: () => void;
disabled?: boolean;
actions?: ReactNode;
};
const CredentialRow = ({
label,
value,
copied,
onCopy,
disabled = false,
actions
}: CredentialRowProps) => (
<div className="group flex flex-col space-y-2 px-4 py-3.5 transition-colors hover:bg-neutral-800/40 sm:flex-row sm:items-center sm:justify-between">
<span className="w-24 shrink-0 text-sm font-medium text-neutral-400">{label}</span>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="min-w-0 flex-1 select-all truncate font-mono text-sm text-neutral-300">
{value}
</span>
<div className="flex shrink-0 items-center space-x-1 opacity-40 transition-opacity group-hover:opacity-100 sm:ml-4">
{actions}
<Button
type="text"
size="small"
className="text-neutral-400 hover:text-white"
icon={
copied ? <CheckIcon size={15} className="text-green-500" /> : <CopyIcon size={15} />
}
disabled={disabled}
onClick={onCopy}
/>
</div>
</div>
</div>
);

View File

@@ -4,11 +4,11 @@ import { useSetAtom } from 'jotai';
import { SquareTerminalIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { keyboardLockAtom } from '@/jotai/keyboard.ts';
export const SerialPort = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setKeyboardLock = useSetAtom(keyboardLockAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [port, setPort] = useState('');
@@ -19,13 +19,12 @@ export const SerialPort = () => {
const [stopBits, setStopBits] = useState(1);
function openModal() {
setIsKeyboardEnable(false);
setKeyboardLock({ source: 'serial-port-modal', locked: true });
setIsModalOpen(true);
}
function closeModal() {
setIsKeyboardEnable(true);
setKeyboardLock({ source: 'serial-port-modal', locked: false });
setIsModalOpen(false);
}
@@ -48,8 +47,12 @@ export const SerialPort = () => {
return;
}
setKeyboardLock({ source: 'serial-port-modal', locked: false });
setIsModalOpen(false);
window.open(`/#terminal?port=${port}&baud=${baudrate}&parity=${parity}&flowControl=${flowControl}&dataBits=${dataBits}&stopBits=${stopBits}`, '_blank');
window.open(
`/#terminal?port=${port}&baud=${baudrate}&parity=${parity}&flowControl=${flowControl}&dataBits=${dataBits}&stopBits=${stopBits}`,
'_blank'
);
}
return (
@@ -113,7 +116,9 @@ export const SerialPort = () => {
</div>
<div className="mt-7 flex items-center space-x-[20px]">
<div className="flex w-[80px] justify-end text-neutral-400">{t('terminal.flowControl')}</div>
<div className="flex w-[80px] justify-end text-neutral-400">
{t('terminal.flowControl')}
</div>
<div className="w-1/2">
<Select
defaultValue="none"
@@ -128,7 +133,6 @@ export const SerialPort = () => {
</div>
</div>
<div className="mt-7 flex items-center space-x-[20px]">
<div className="flex w-[80px] justify-end text-neutral-400">{t('terminal.dataBits')}</div>
<div className="w-1/2">
@@ -169,4 +173,4 @@ export const SerialPort = () => {
</Modal>
</>
);
};
};

View File

@@ -7,7 +7,7 @@ import { Eye, EyeClosed, NetworkIcon, Pencil, SendIcon, Trash2Icon } from 'lucid
import { useTranslation } from 'react-i18next';
import { deleteWolMac, getWolMacs, setWolMacName, wol } from '@/api/network.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { keyboardLockAtom } from '@/jotai/keyboard.ts';
import { MenuItem } from '@/components/menu-item.tsx';
interface MacItem {
@@ -21,7 +21,7 @@ interface MacItem {
export const Wol = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setKeyboardLock = useSetAtom(keyboardLockAtom);
const [input, setInput] = useState('');
const [status, setStatus] = useState('');
@@ -34,12 +34,13 @@ export const Wol = () => {
function handleOpenChange(open: boolean) {
if (open) {
getMacs();
setIsKeyboardEnable(false);
setKeyboardLock({ source: 'wol-popover', locked: true });
} else {
setInput('');
setStatus('');
setLog('');
setIsKeyboardEnable(true);
setKeyboardLock({ source: 'wol-popover', locked: false });
setKeyboardLock({ source: 'wol-edit-input', locked: false });
}
}
@@ -175,8 +176,8 @@ export const Wol = () => {
{item.isEdit ? (
<Input
placeholder={item.mac}
onFocus={() => setIsKeyboardEnable(false)}
onBlur={() => setIsKeyboardEnable(true)}
onFocus={() => setKeyboardLock({ source: 'wol-edit-input', locked: true })}
onBlur={() => setKeyboardLock({ source: 'wol-edit-input', locked: false })}
defaultValue={item.name}
onPressEnter={(e) => setMacName(e, item.mac)}
/>

View File

@@ -46,6 +46,9 @@ export const Absolute = () => {
const screen = document.getElementById('screen') as HTMLVideoElement | null;
if (!screen) return;
const target = screen;
const mouse = mouseRef.current;
let pendingMove: { x: number; y: number } | null = null;
let moveFrame: number | null = null;
target.addEventListener('mousedown', handleMouseDown);
target.addEventListener('mouseup', handleMouseUp);
@@ -61,15 +64,50 @@ export const Absolute = () => {
target.addEventListener('touchcancel', handleTouchCancel);
}
// Mouse event handler
function handleMouseEvent(event: MouseAbsoluteEvent) {
let report: Uint8Array;
switch (event.type) {
case 'mousedown':
mouse.buttonDown(event.button);
report = mouse.buildButtonReport(lastPosRef.current.x, lastPosRef.current.y);
break;
case 'mouseup':
mouse.buttonUp(event.button);
report = mouse.buildButtonReport(lastPosRef.current.x, lastPosRef.current.y);
break;
case 'wheel':
report = mouse.buildReport(lastPosRef.current.x, lastPosRef.current.y, event.deltaY);
break;
case 'move':
report = mouse.buildReport(event.x, event.y);
lastPosRef.current = { x: event.x, y: event.y };
break;
default:
report = mouse.buildReport(lastPosRef.current.x, lastPosRef.current.y);
break;
}
sendReport(report);
}
function sendReport(report: Uint8Array) {
const data = new Uint8Array([MessageEvent.Mouse, ...report]);
client.send(data);
}
// Mouse down event
function handleMouseDown(e: MouseEvent) {
disableEvent(e);
flushMouseMove();
handleMouseEvent({ type: 'mousedown', button: e.button });
}
// Mouse up event
function handleMouseUp(e: MouseEvent) {
disableEvent(e);
flushMouseMove();
handleMouseEvent({ type: 'mouseup', button: e.button });
}
@@ -77,7 +115,7 @@ export const Absolute = () => {
function handleMouseMove(e: MouseEvent) {
disableEvent(e);
const { x, y } = getCoordinate(e);
handleMouseEvent({ type: 'move', x, y });
queueMouseMove(x, y);
}
// Mouse wheel event
@@ -93,6 +131,7 @@ export const Absolute = () => {
return;
}
flushMouseMove();
const deltaY = (e.deltaY > 0 ? 1 : -1) * scrollDirection;
handleMouseEvent({ type: 'wheel', deltaY });
lastScrollTimeRef.current = currentTime;
@@ -216,6 +255,7 @@ export const Absolute = () => {
handleMouseEvent({ type: 'mouseup', button: MouseButton.Left });
}, 50);
} else if (pressedButtonRef.current !== null) {
flushMouseMove();
handleMouseEvent({ type: 'mouseup', button: pressedButtonRef.current! });
}
@@ -235,6 +275,7 @@ export const Absolute = () => {
}
if (pressedButtonRef.current !== null) {
flushMouseMove();
handleMouseEvent({ type: 'mouseup', button: pressedButtonRef.current! });
}
@@ -289,7 +330,37 @@ export const Absolute = () => {
return { x, y };
}
function queueMouseMove(x: number, y: number) {
pendingMove = { x, y };
if (moveFrame !== null) {
return;
}
moveFrame = requestAnimationFrame(() => {
moveFrame = null;
flushMouseMove();
});
}
function flushMouseMove() {
if (moveFrame !== null) {
cancelAnimationFrame(moveFrame);
moveFrame = null;
}
if (pendingMove === null) {
return;
}
const move = pendingMove;
pendingMove = null;
handleMouseEvent({ type: 'move', x: move.x, y: move.y });
}
return () => {
if (moveFrame !== null) {
cancelAnimationFrame(moveFrame);
}
sendReport(mouse.reset(lastPosRef.current.x, lastPosRef.current.y));
target.removeEventListener('mousemove', handleMouseMove);
target.removeEventListener('mousedown', handleMouseDown);
target.removeEventListener('mouseup', handleMouseUp);
@@ -307,40 +378,6 @@ export const Absolute = () => {
};
}, [isBigScreen, resolution, scrollDirection, scrollInterval]);
// Mouse event handler
function handleMouseEvent(event: MouseAbsoluteEvent) {
let report: Uint8Array;
const mouse = mouseRef.current;
switch (event.type) {
case 'mousedown':
mouse.buttonDown(event.button);
report = mouse.buildButtonReport(lastPosRef.current.x, lastPosRef.current.y);
break;
case 'mouseup':
mouse.buttonUp(event.button);
report = mouse.buildButtonReport(lastPosRef.current.x, lastPosRef.current.y);
break;
case 'wheel':
report = mouse.buildReport(lastPosRef.current.x, lastPosRef.current.y, event.deltaY);
break;
case 'move':
report = mouse.buildReport(event.x, event.y);
lastPosRef.current = { x: event.x, y: event.y };
break;
default:
report = mouse.buildReport(lastPosRef.current.x, lastPosRef.current.y);
break;
}
sendReport(report);
}
function sendReport(report: Uint8Array) {
const data = new Uint8Array([MessageEvent.Mouse, ...report]);
client.send(data);
}
// disable default events
function disableEvent(event: any) {
event.preventDefault();

View File

@@ -25,6 +25,7 @@ export const Relative = () => {
useEffect(() => {
const screen = document.getElementById('screen');
if (!screen) return;
const mouse = mouseRef.current;
showMessage();
@@ -94,6 +95,8 @@ export const Relative = () => {
}
return () => {
const release = mouse.reset();
client.send(new Uint8Array([MessageEvent.Mouse, ...release]));
screen.removeEventListener('click', handleMouseClick);
screen.removeEventListener('mousemove', handleMouseMove);
screen.removeEventListener('mousedown', handleMouseDown);

View File

@@ -48,7 +48,7 @@ export const ActionOverlay = () => {
};
}, [takeover.active]);
if (!takeover.active || !rect || rect.width <= 0 || rect.height <= 0) {
if (!takeover.active || !overlay.visible || !rect || rect.width <= 0 || rect.height <= 0) {
return null;
}

View File

@@ -0,0 +1,17 @@
export const PICOCLAW_INPUT_KEYBOARD_LOCK_SOURCE = 'picoclaw-input';
export const PICOCLAW_MODEL_CONFIG_KEYBOARD_LOCK_SOURCE = 'picoclaw-model-config';
export function releasePicoclawInputFocus() {
if (typeof document === 'undefined') {
return;
}
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) {
return;
}
if (activeElement.closest('[data-picoclaw-sidebar="true"]')) {
activeElement.blur();
}
}

View File

@@ -4,52 +4,75 @@ import { useSetAtom } from 'jotai';
import { PlusIcon, SendIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { keyboardLockAtom } from '@/jotai/keyboard.ts';
import type { PicoclawTransportState } from '@/jotai/picoclaw.ts';
import { PICOCLAW_INPUT_KEYBOARD_LOCK_SOURCE } from './keyboard-lock.ts';
type MessageInputProps = {
transportState: PicoclawTransportState;
onSend: (content: string) => void | Promise<void>;
disabled?: boolean;
onSend: (content: string) => boolean | void | Promise<boolean | void>;
onNewConversation: () => void | Promise<void>;
disableNewConversation?: boolean;
};
export const MessageInput = ({
transportState,
disabled,
onSend,
onNewConversation,
disableNewConversation
}: MessageInputProps) => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setKeyboardLock = useSetAtom(keyboardLockAtom);
const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const isComposingRef = useRef(false);
const isReady = transportState === 'connected';
const isConnecting = transportState === 'connecting';
const canSubmit = !disabled && !isConnecting;
useEffect(() => {
setIsKeyboardEnable(false);
return () => {
setIsKeyboardEnable(true);
setKeyboardLock({ source: PICOCLAW_INPUT_KEYBOARD_LOCK_SOURCE, locked: false });
};
}, [setIsKeyboardEnable]);
}, [setKeyboardLock]);
useEffect(() => {
if (!isConnecting) {
return;
}
textareaRef.current?.blur();
setKeyboardLock({ source: PICOCLAW_INPUT_KEYBOARD_LOCK_SOURCE, locked: false });
}, [isConnecting, setKeyboardLock]);
async function submit() {
const content = value.trim();
if (!content || !isReady) return;
await onSend(content);
setValue('');
if (!content || !canSubmit) return;
const sent = await onSend(content);
if (sent !== false) {
setValue('');
}
}
return (
<div className="relative">
<textarea
ref={textareaRef}
rows={3}
value={value}
disabled={!isReady}
placeholder={isReady ? t('picoclaw.inputPlaceholder') : '...'}
disabled={disabled || isConnecting}
placeholder={isConnecting ? '...' : t('picoclaw.inputPlaceholder')}
onChange={(e) => setValue(e.target.value)}
onFocus={() =>
setKeyboardLock({ source: PICOCLAW_INPUT_KEYBOARD_LOCK_SOURCE, locked: true })
}
onBlur={() =>
setKeyboardLock({ source: PICOCLAW_INPUT_KEYBOARD_LOCK_SOURCE, locked: false })
}
onCompositionStart={() => {
isComposingRef.current = true;
}}
@@ -75,7 +98,7 @@ export const MessageInput = ({
type="text"
icon={<PlusIcon size={14} />}
onClick={() => void onNewConversation()}
disabled={disableNewConversation}
disabled={disabled || disableNewConversation}
className="absolute bottom-2.5 right-11 !flex !h-7 !w-7 !items-center !justify-center !rounded-lg !border !border-white/[0.08]"
title={t('picoclaw.newConversation')}
/>
@@ -83,7 +106,7 @@ export const MessageInput = ({
type="primary"
icon={<SendIcon size={14} />}
onClick={() => void submit()}
disabled={!isReady || !value.trim()}
disabled={!canSubmit || !value.trim()}
className="absolute bottom-2.5 right-2.5 !flex !h-7 !w-7 !items-center !justify-center !rounded-lg"
title={t('picoclaw.send')}
/>

View File

@@ -1,5 +1,6 @@
import type { TFunction } from 'i18next';
import type { AIControlMode, AIControlStatus } from '@/jotai/ai-control.ts';
import type {
PicoclawRunState,
PicoclawRuntimeStatus,
@@ -8,6 +9,27 @@ import type {
export type PicoclawSidebarMode = 'loading' | 'install' | 'model' | 'chat';
export type PicoclawControlPrimaryAction = 'grant' | 'release' | 'none';
export type PicoclawControlViewModel = {
mode: AIControlMode;
label: string;
description: string;
canDeviceWrite: boolean;
isTransitioning: boolean;
isActionPending: boolean;
primaryAction: PicoclawControlPrimaryAction;
primaryActionLabel: string;
severity: 'success' | 'warning' | 'neutral' | 'info';
};
type PicoclawControlViewModelOptions = {
aiControlStatus: AIControlStatus | null;
runtimeStatus: PicoclawRuntimeStatus | null;
isReleasingControl: boolean;
t: TFunction;
};
export function isPicoclawRuntimeInstalling(
runtimeStatus: PicoclawRuntimeStatus | null | undefined
) {
@@ -35,7 +57,7 @@ export function canConnectGateway(runtimeStatus: PicoclawRuntimeStatus | null) {
return false;
}
if (isPicoclawRuntimeInstalling(runtimeStatus)) {
if (isPicoclawRuntimeInstalling(runtimeStatus) || runtimeStatus.restoring === true) {
return false;
}
@@ -43,7 +65,15 @@ export function canConnectGateway(runtimeStatus: PicoclawRuntimeStatus | null) {
return false;
}
return runtimeStatus.ready === true;
if (runtimeStatus.transitioning === true || runtimeStatus.control?.transitioning === true) {
return false;
}
const canChat =
runtimeStatus.capabilities?.chat ??
(runtimeStatus.ready === true && runtimeStatus.control_mode !== 'mcp');
return runtimeStatus.ready === true && canChat;
}
export function getPicoclawSidebarStatusColor(
@@ -56,7 +86,11 @@ export function getPicoclawSidebarStatusColor(
return '#38bdf8';
}
if (isPicoclawRuntimeInstalling(runtimeStatus) || transportState === 'connecting') {
if (
isPicoclawRuntimeInstalling(runtimeStatus) ||
runtimeStatus.restoring === true ||
transportState === 'connecting'
) {
return '#38bdf8';
}
@@ -68,6 +102,10 @@ export function getPicoclawSidebarStatusColor(
return '#ef4444';
}
if (transportState === 'disconnected') {
return '#f59e0b';
}
if (transportState === 'connected' && runState === 'busy') {
return '#38bdf8';
}
@@ -100,8 +138,12 @@ export function getPicoclawSidebarConnectionLabel(
if (runtimeStatus.ready !== true) {
switch (runtimeStatus.status) {
case 'restoring':
return t('picoclaw.connection.runtime.restoring');
case 'checking':
return t('picoclaw.connection.runtime.checking');
case 'blocked_by_mcp':
return t('picoclaw.connection.runtime.blockedByMCP');
case 'config_error':
return t('picoclaw.connection.runtime.configError');
case 'unavailable':
@@ -114,6 +156,17 @@ export function getPicoclawSidebarConnectionLabel(
}
}
const controlMode = runtimeStatus.control?.mode ?? runtimeStatus.control_mode;
const canControl =
runtimeStatus.control?.can_control ??
(runtimeStatus.control_mode === 'picoclaw' && runtimeStatus.transitioning !== true);
if (controlMode === 'mcp' && !canControl) {
return t('picoclaw.connection.runtime.blockedByMCP');
}
if (controlMode === 'off' && !canControl) {
return t('picoclaw.control.off');
}
if (transportState === 'connected') {
return `${t('picoclaw.connection.transport.connected')} · ${t(`picoclaw.connection.run.${runState}`)}`;
}
@@ -126,5 +179,111 @@ export function getPicoclawSidebarConnectionLabel(
return t('picoclaw.connection.runtime.unavailable');
}
if (transportState === 'disconnected') {
return t('picoclaw.connection.transport.disconnected');
}
return t('picoclaw.connection.runtime.ready');
}
export function derivePicoclawControlViewModel({
aiControlStatus,
runtimeStatus,
isReleasingControl,
t
}: PicoclawControlViewModelOptions): PicoclawControlViewModel {
const mode =
runtimeStatus?.control?.mode ?? runtimeStatus?.control_mode ?? aiControlStatus?.mode ?? 'off';
const backendTransitioning =
runtimeStatus?.control?.transitioning ??
runtimeStatus?.transitioning ??
aiControlStatus?.transitioning ??
false;
const isTransitioning = isReleasingControl || backendTransitioning;
const runtimeCanDeviceWrite = runtimeStatus
? runtimeStatus.control?.can_control ??
(runtimeStatus.control_mode === 'picoclaw' && runtimeStatus.transitioning !== true)
: undefined;
const canDeviceWrite =
!isReleasingControl && (runtimeCanDeviceWrite ?? aiControlStatus?.canControlPicoclaw ?? false);
const primaryAction: PicoclawControlPrimaryAction = isTransitioning
? 'none'
: canDeviceWrite
? 'release'
: 'grant';
const primaryActionLabel = isReleasingControl
? t('picoclaw.control.releasing')
: backendTransitioning
? t('picoclaw.control.switching')
: canDeviceWrite
? t('picoclaw.control.release')
: t('picoclaw.control.grant');
if (isReleasingControl) {
return {
mode,
label: t('picoclaw.control.releasingLabel'),
description: t('picoclaw.control.releasingDescription'),
canDeviceWrite: false,
isTransitioning: true,
isActionPending: true,
primaryAction,
primaryActionLabel,
severity: 'info'
};
}
if (backendTransitioning) {
return {
mode,
label: t('picoclaw.control.transitioning'),
description: t('picoclaw.control.transitioningDescription'),
canDeviceWrite: false,
isTransitioning: true,
isActionPending: true,
primaryAction,
primaryActionLabel,
severity: 'info'
};
}
if (mode === 'picoclaw') {
return {
mode,
label: t('picoclaw.control.picoclaw'),
description: t('picoclaw.control.picoclawDescription'),
canDeviceWrite,
isTransitioning: false,
isActionPending: false,
primaryAction,
primaryActionLabel,
severity: 'success'
};
}
if (mode === 'mcp') {
return {
mode,
label: t('picoclaw.control.mcp'),
description: t('picoclaw.control.mcpDescription'),
canDeviceWrite: false,
isTransitioning: false,
isActionPending: false,
primaryAction,
primaryActionLabel,
severity: 'warning'
};
}
return {
mode,
label: t('picoclaw.control.off'),
description: t('picoclaw.control.offDescription'),
canDeviceWrite: false,
isTransitioning: false,
isActionPending: false,
primaryAction,
primaryActionLabel,
severity: 'neutral'
};
}

View File

@@ -1,4 +1,4 @@
import type { Dispatch, SetStateAction } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { TFunction } from 'i18next';
import {
@@ -7,6 +7,7 @@ import {
getRuntimeStatus,
installRuntime,
picoclawGateway,
setAIControlMode,
setPicoclawAgentProfile,
setPicoclawModelConfig,
startRuntime,
@@ -18,6 +19,7 @@ import {
setPicoclawRuntimeInstallSnapshot,
type PicoclawRuntimeInstallSnapshot
} from '@/lib/picoclaw-storage.ts';
import { normalizeAIControlStatus, type AIControlStatus } from '@/jotai/ai-control.ts';
import type {
PicoclawChatMessage,
PicoclawOverlayState,
@@ -27,11 +29,32 @@ import type {
PicoclawTransportState
} from '@/jotai/picoclaw.ts';
import { PICOCLAW_INPUT_KEYBOARD_LOCK_SOURCE, releasePicoclawInputFocus } from './keyboard-lock.ts';
import { createErrorMessage, createStatusMessage, HIDDEN_OVERLAY } from './message-utils.ts';
type RuntimeStatusSetter = Dispatch<SetStateAction<PicoclawRuntimeStatus | null>>;
type AIControlStatusSetter = Dispatch<SetStateAction<AIControlStatus | null>>;
type MessageSetter = Dispatch<SetStateAction<PicoclawChatMessage[]>>;
type TakeoverSetter = Dispatch<SetStateAction<PicoclawTakeoverState>>;
type KeyboardLockSetter = (action: { source: string; locked: boolean }) => void;
export type PicoclawMutation =
| 'none'
| 'control_grant'
| 'control_release'
| 'runtime_start'
| 'runtime_stop'
| 'runtime_install'
| 'runtime_uninstall'
| 'agent_switch'
| 'model_save'
| 'session_switch';
export type PicoclawRefreshOptions = {
force?: boolean;
preserveRuntimeOnError?: boolean;
allowDuringMutation?: boolean;
};
type PicoclawSidebarActionOptions = {
t: TFunction;
@@ -41,7 +64,12 @@ type PicoclawSidebarActionOptions = {
modelApiBase: string;
modelApiKey: string;
modelIdentifier: string;
refreshStatePromiseRef: MutableRefObject<Promise<PicoclawRuntimeStatus | null> | null>;
mutationRef: MutableRefObject<PicoclawMutation>;
mutationEpochRef: MutableRefObject<number>;
refreshRequestSeqRef: MutableRefObject<number>;
setRuntimeStatus: RuntimeStatusSetter;
setAIControlStatus: AIControlStatusSetter;
setMessages: MessageSetter;
setTakeover: TakeoverSetter;
setOverlay: Dispatch<SetStateAction<PicoclawOverlayState>>;
@@ -54,6 +82,9 @@ type PicoclawSidebarActionOptions = {
setIsSavingModelConfig: Dispatch<SetStateAction<boolean>>;
setIsSwitchingAgent: Dispatch<SetStateAction<boolean>>;
setIsUninstallRequestPending: Dispatch<SetStateAction<boolean>>;
setIsReleasingControl: Dispatch<SetStateAction<boolean>>;
setPicoclawMutation: Dispatch<SetStateAction<PicoclawMutation>>;
setKeyboardLock: KeyboardLockSetter;
};
export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptions) {
@@ -65,7 +96,12 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
modelApiBase,
modelApiKey,
modelIdentifier,
refreshStatePromiseRef,
mutationRef,
mutationEpochRef,
refreshRequestSeqRef,
setRuntimeStatus,
setAIControlStatus,
setMessages,
setTakeover,
setOverlay,
@@ -77,25 +113,150 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
setInstallSnapshot,
setIsSavingModelConfig,
setIsSwitchingAgent,
setIsUninstallRequestPending
setIsUninstallRequestPending,
setIsReleasingControl,
setPicoclawMutation,
setKeyboardLock
} = options;
async function refreshState() {
let nextRuntimeStatus = runtimeStatus;
let runtimeRsp;
function syncAIControlStatus(value: unknown, source: string) {
const nextControlStatus = normalizeAIControlStatus(value, source);
if (nextControlStatus) {
setAIControlStatus(nextControlStatus);
}
}
function syncRuntimeStatus(status: PicoclawRuntimeStatus, source = 'picoclaw_runtime_status') {
setRuntimeStatus(status);
syncAIControlStatus(status, source);
return status;
}
function beginMutation(mutation: PicoclawMutation) {
if (mutationRef.current !== 'none') {
return false;
}
mutationRef.current = mutation;
mutationEpochRef.current += 1;
refreshStatePromiseRef.current = null;
setPicoclawMutation(mutation);
return true;
}
function endMutation(mutation: PicoclawMutation) {
if (mutationRef.current !== mutation) {
return;
}
mutationRef.current = 'none';
mutationEpochRef.current += 1;
refreshStatePromiseRef.current = null;
setPicoclawMutation('none');
}
function markRuntimeStatusUnavailable(source: string) {
releasePicoclawInputFocus();
setKeyboardLock({ source: PICOCLAW_INPUT_KEYBOARD_LOCK_SOURCE, locked: false });
setTakeover((current) => ({
...current,
active: false,
reason: source
}));
setOverlay(HIDDEN_OVERLAY);
setTransportState('error');
setRunState('idle');
setAIControlStatus((current) =>
current
? {
...current,
canControlPicoclaw: false,
source
}
: current
);
setRuntimeStatus((current) => {
if (
!current ||
current.installing ||
(current.ready !== true &&
current.status !== 'ready' &&
current.capabilities?.chat !== true)
) {
return current;
}
return {
...current,
ready: false,
restoring: false,
status: 'unavailable',
current_session: '',
control: current.control
? {
...current.control,
can_control: false
}
: current.control,
capabilities: {
chat: false,
read_only_tools: false,
device_write: false
}
};
});
}
async function refreshState(options: PicoclawRefreshOptions = {}) {
const force = options.force === true;
if (!force && mutationRef.current !== 'none' && options.allowDuringMutation !== true) {
return runtimeStatus;
}
if (!force && refreshStatePromiseRef.current) {
return refreshStatePromiseRef.current;
}
const requestId = refreshRequestSeqRef.current + 1;
refreshRequestSeqRef.current = requestId;
const requestEpoch = mutationEpochRef.current;
const refreshPromise = (async () => {
let runtimeRsp;
try {
runtimeRsp = await getRuntimeStatus();
} catch {
if (options.preserveRuntimeOnError !== true) {
markRuntimeStatusUnavailable('runtime_status_unavailable');
}
return null;
}
if (runtimeRsp.code === 0) {
const nextStatus = runtimeRsp.data as PicoclawRuntimeStatus;
const canCommit =
requestId === refreshRequestSeqRef.current &&
(force || requestEpoch === mutationEpochRef.current) &&
(mutationRef.current === 'none' || options.allowDuringMutation === true);
return canCommit ? syncRuntimeStatus(nextStatus) : nextStatus;
}
if (options.preserveRuntimeOnError !== true) {
markRuntimeStatusUnavailable('runtime_status_error');
}
return null;
})();
if (!force) {
refreshStatePromiseRef.current = refreshPromise;
}
try {
runtimeRsp = await getRuntimeStatus();
} catch {
return nextRuntimeStatus;
return await refreshPromise;
} finally {
if (!force && refreshStatePromiseRef.current === refreshPromise) {
refreshStatePromiseRef.current = null;
}
}
if (runtimeRsp.code === 0) {
nextRuntimeStatus = runtimeRsp.data;
setRuntimeStatus(runtimeRsp.data);
}
return nextRuntimeStatus;
}
function isUnexpectedEOFError(error: unknown) {
@@ -112,130 +273,443 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
return false;
}
const controlMode = status.control?.mode ?? status.control_mode;
const isTransitioning = status.control?.transitioning === true || status.transitioning === true;
if (shouldStop) {
return status.ready !== true;
return (
status.ready === false &&
status.status === 'stopped' &&
status.runtime_intent?.desired_running === false &&
controlMode === 'off'
);
}
return status.ready === true;
return (
status.ready === true &&
status.status === 'ready' &&
status.installed !== false &&
status.installing !== true &&
status.model_configured !== false &&
controlMode === 'picoclaw' &&
!isTransitioning &&
status.runtime_intent?.desired_running === true
);
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function parseRuntimeStatus(value: unknown) {
if (!isObject(value)) {
return null;
}
if (typeof value.ready !== 'boolean' || typeof value.control_mode !== 'string') {
return null;
}
return value as PicoclawRuntimeStatus;
}
function runtimeStatusFromResponse(response: unknown) {
if (!isObject(response)) {
return null;
}
const data = response.data;
if (!isObject(data)) {
return null;
}
return parseRuntimeStatus(data.status) ?? parseRuntimeStatus(data.runtime);
}
function responseCode(response: unknown) {
if (!isObject(response)) {
return undefined;
}
return response.code;
}
function responseMessage(response: unknown) {
if (!isObject(response)) {
return '';
}
const message = response.message || response.msg;
return typeof message === 'string' ? message : '';
}
function runtimeStatusError(status: PicoclawRuntimeStatus | null) {
return status?.last_error || status?.config_error || '';
}
function responseCleanupWarning(response: unknown) {
if (!isObject(response) || !isObject(response.data)) {
return '';
}
const warning = response.data.cleanup_warning;
return typeof warning === 'string' ? warning : '';
}
function debugTiming(label: string, startedAt: number, fields?: Record<string, unknown>) {
if (typeof console === 'undefined' || typeof console.debug !== 'function') {
return;
}
console.debug('[picoclaw]', label, {
...(fields ?? {}),
elapsedMs: Math.round(performance.now() - startedAt)
});
}
function setRuntimeControlStatus(mode: 'off' | 'mcp' | 'picoclaw', transitioning: boolean) {
setRuntimeStatus((current) =>
current
? {
...current,
control_mode: mode,
transitioning,
control: {
...(current.control ?? {
mode,
transitioning,
can_control: false
}),
mode,
transitioning,
can_control: mode === 'picoclaw' && !transitioning
},
capabilities: {
chat: current.capabilities?.chat ?? current.ready === true,
read_only_tools: current.capabilities?.read_only_tools ?? current.ready === true,
device_write: mode === 'picoclaw' && !transitioning
}
}
: current
);
}
async function applyRuntimeToggleSuccess(status: PicoclawRuntimeStatus, shouldStop: boolean) {
syncRuntimeStatus(status, shouldStop ? 'runtime_stop' : 'runtime_start');
setMessages((current) => [
...current,
createStatusMessage(
t(shouldStop ? 'picoclaw.status.runtimeStopped' : 'picoclaw.status.runtimeStarted')
)
]);
if (shouldStop) {
void closeGateway();
setTakeover({
active: false,
sessionId: picoclawGateway.getSessionId(),
reason: 'runtime_stopped'
});
setOverlay(HIDDEN_OVERLAY);
setTransportState('disconnected');
setRunState('idle');
return;
}
if (transportState !== 'connected') {
try {
await connectGateway();
} catch {
// handled by gateway events
}
}
}
async function confirmRuntimeToggleResult(shouldStop: boolean) {
const attempts = 5;
for (let index = 0; index < attempts; index += 1) {
const latestRuntimeStatus = await refreshState({
force: true,
preserveRuntimeOnError: true,
allowDuringMutation: true
});
if (latestRuntimeStatus && isExpectedRuntimeState(latestRuntimeStatus, shouldStop)) {
await applyRuntimeToggleSuccess(latestRuntimeStatus, shouldStop);
return latestRuntimeStatus;
}
if (index < attempts - 1) {
await new Promise((resolve) => window.setTimeout(resolve, 500));
}
}
return null;
}
function runtimeToggleFailureMessage(
raw: unknown,
status: PicoclawRuntimeStatus | null,
shouldStop: boolean
) {
if (raw instanceof Error && !isUnexpectedEOFError(raw)) {
return raw.message;
}
return (
responseMessage(raw) ||
runtimeStatusError(status) ||
t(shouldStop ? 'picoclaw.status.runtimeStopFailed' : 'picoclaw.status.runtimeStartFailed')
);
}
async function handleStartRuntime() {
const isRuntimeReady = runtimeStatus?.ready === true;
const mutation: PicoclawMutation = isRuntimeReady ? 'runtime_stop' : 'runtime_start';
if (!beginMutation(mutation)) {
return;
}
setIsTogglingRuntime(true);
try {
const isRuntimeReady = runtimeStatus?.ready === true;
let response;
try {
response = isRuntimeReady ? await stopRuntime() : await startRuntime();
} catch (error) {
const latestRuntimeStatus = await refreshState();
if (
isUnexpectedEOFError(error) &&
isExpectedRuntimeState(latestRuntimeStatus ?? null, isRuntimeReady)
) {
setRuntimeStatus(latestRuntimeStatus ?? null);
setMessages((current) => [
...current,
createStatusMessage(
t(
isRuntimeReady ? 'picoclaw.status.runtimeStopped' : 'picoclaw.status.runtimeStarted'
)
)
]);
if (isRuntimeReady) {
void closeGateway();
setTakeover({
active: false,
sessionId: picoclawGateway.getSessionId(),
reason: 'runtime_stopped'
});
setOverlay(HIDDEN_OVERLAY);
setTransportState('disconnected');
setRunState('idle');
} else if (transportState !== 'connected') {
try {
await connectGateway();
} catch {
// handled by gateway events
}
}
const confirmedStatus = await confirmRuntimeToggleResult(isRuntimeReady);
if (confirmedStatus) {
return;
}
const errorMessage =
error instanceof Error
? error.message
: t(
isRuntimeReady
? 'picoclaw.status.runtimeStopFailed'
: 'picoclaw.status.runtimeStartFailed'
);
setMessages((current) => [
...current,
createErrorMessage({
code: isRuntimeReady ? 'RUNTIME_STOP_FAILED' : 'RUNTIME_START_FAILED',
message: errorMessage,
message: runtimeToggleFailureMessage(error, null, isRuntimeReady),
raw: error
})
]);
return;
}
if (response.code === 0) {
setRuntimeStatus(response.data.status);
setMessages((current) => [
...current,
createStatusMessage(
t(isRuntimeReady ? 'picoclaw.status.runtimeStopped' : 'picoclaw.status.runtimeStarted')
)
]);
if (isRuntimeReady) {
void closeGateway();
setTakeover({
active: false,
sessionId: picoclawGateway.getSessionId(),
reason: 'runtime_stopped'
});
setOverlay(HIDDEN_OVERLAY);
setTransportState('disconnected');
setRunState('idle');
} else if (transportState !== 'connected') {
try {
await connectGateway();
} catch {
// handled by gateway events
}
}
const responseStatus = runtimeStatusFromResponse(response);
if (
responseCode(response) === 0 &&
responseStatus &&
isExpectedRuntimeState(responseStatus, isRuntimeReady)
) {
await applyRuntimeToggleSuccess(responseStatus, isRuntimeReady);
} else if (await confirmRuntimeToggleResult(isRuntimeReady)) {
return;
} else {
const errorMessage =
(response as { message?: string; msg?: string }).message ||
(response as { message?: string; msg?: string }).msg ||
t(
isRuntimeReady
? 'picoclaw.status.runtimeStopFailed'
: 'picoclaw.status.runtimeStartFailed'
);
if (responseStatus) {
syncRuntimeStatus(responseStatus, isRuntimeReady ? 'runtime_stop' : 'runtime_start');
}
setMessages((current) => [
...current,
createErrorMessage({
code: isRuntimeReady ? 'RUNTIME_STOP_FAILED' : 'RUNTIME_START_FAILED',
message: errorMessage,
message: runtimeToggleFailureMessage(response, responseStatus, isRuntimeReady),
raw: response
})
]);
}
await refreshState();
await refreshState({ force: true, allowDuringMutation: true });
} finally {
setIsTogglingRuntime(false);
endMutation(mutation);
}
}
async function handleGrantControl() {
if (!beginMutation('control_grant')) {
return false;
}
try {
const response = await setAIControlMode('picoclaw');
if (response.code !== 0) {
const errorMessage = responseMessage(response) || t('picoclaw.control.grantFailed');
setMessages((current) => [
...current,
createErrorMessage({
code: 'CONTROL_SWITCH_FAILED',
message: errorMessage,
raw: response
})
]);
await refreshState({
force: true,
preserveRuntimeOnError: true,
allowDuringMutation: true
});
return false;
}
syncAIControlStatus(response.data, 'ai_control_mode');
await refreshState({ force: true, allowDuringMutation: true });
setMessages((current) => [
...current,
createStatusMessage(
t('picoclaw.control.granted', { defaultValue: 'PicoClaw control granted' })
)
]);
return true;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t('picoclaw.control.grantFailed');
setMessages((current) => [
...current,
createErrorMessage({
code: 'CONTROL_SWITCH_FAILED',
message: errorMessage,
raw: error
})
]);
await refreshState({
force: true,
preserveRuntimeOnError: true,
allowDuringMutation: true
});
return false;
} finally {
endMutation('control_grant');
}
}
async function handleReleaseControl() {
if (!beginMutation('control_release')) {
return false;
}
const totalStartedAt = performance.now();
setIsReleasingControl(true);
releasePicoclawInputFocus();
setKeyboardLock({ source: PICOCLAW_INPUT_KEYBOARD_LOCK_SOURCE, locked: false });
setTakeover({
active: false,
sessionId: picoclawGateway.getSessionId(),
reason: 'control_release_requested'
});
setOverlay(HIDDEN_OVERLAY);
setRunState('idle');
setAIControlStatus({
mode: 'off',
transitioning: true,
canControlPicoclaw: false,
source: 'control_release_requested'
});
setRuntimeControlStatus('off', true);
try {
const switchStartedAt = performance.now();
const response = await setAIControlMode('off');
debugTiming('release_control.setAIControlMode', switchStartedAt, {
code: responseCode(response)
});
if (response.code !== 0) {
const errorMessage = responseMessage(response) || t('picoclaw.control.releaseFailed');
setMessages((current) => [
...current,
createErrorMessage({
code: 'CONTROL_SWITCH_FAILED',
message: errorMessage,
raw: response
})
]);
const rollbackStatus = await refreshState({
force: true,
preserveRuntimeOnError: true,
allowDuringMutation: true
});
debugTiming('release_control.rollbackRefresh', totalStartedAt, {
status: rollbackStatus?.status,
controlMode: rollbackStatus?.control?.mode ?? rollbackStatus?.control_mode
});
return false;
}
syncAIControlStatus(response.data, 'ai_control_mode');
const responseStatus = runtimeStatusFromResponse(response);
if (responseStatus) {
syncRuntimeStatus(responseStatus, 'ai_control_mode');
} else {
setRuntimeControlStatus('off', false);
}
setTakeover({
active: false,
sessionId: picoclawGateway.getSessionId(),
reason: 'control_released'
});
setOverlay(HIDDEN_OVERLAY);
setRunState('idle');
setMessages((current) => [
...current,
createStatusMessage(
t('picoclaw.control.released', { defaultValue: 'PicoClaw control released' })
)
]);
const cleanupWarning = responseCleanupWarning(response);
if (cleanupWarning) {
setMessages((current) => [
...current,
createErrorMessage({
code: 'CONTROL_RELEASE_CLEANUP_WARNING',
message: cleanupWarning,
raw: response
})
]);
}
const refreshStartedAt = performance.now();
const latestStatus = await refreshState({
force: true,
preserveRuntimeOnError: true,
allowDuringMutation: true
});
debugTiming('release_control.refreshState', refreshStartedAt, {
status: latestStatus?.status,
controlMode: latestStatus?.control?.mode ?? latestStatus?.control_mode
});
const latestControlMode = latestStatus?.control?.mode ?? latestStatus?.control_mode;
if (latestStatus && latestControlMode !== 'off') {
setMessages((current) => [
...current,
createErrorMessage({
code: 'CONTROL_RELEASE_VERIFY_FAILED',
message: t('picoclaw.control.releaseFailed'),
raw: latestStatus
})
]);
}
debugTiming('release_control.total', totalStartedAt, {
verifiedControlMode: latestControlMode
});
return true;
} catch (error) {
debugTiming('release_control.failed', totalStartedAt, {
message: error instanceof Error ? error.message : String(error)
});
const errorMessage =
error instanceof Error ? error.message : t('picoclaw.control.releaseFailed');
setMessages((current) => [
...current,
createErrorMessage({
code: 'CONTROL_SWITCH_FAILED',
message: errorMessage,
raw: error
})
]);
await refreshState({
force: true,
preserveRuntimeOnError: true,
allowDuringMutation: true
});
return false;
} finally {
setIsReleasingControl(false);
endMutation('control_release');
}
}
async function handleInstallRuntime() {
if (!beginMutation('runtime_install')) {
return;
}
setIsInstallRequestPending(true);
const pendingSnapshot = {
installing: true,
@@ -249,7 +723,7 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
try {
const response = await installRuntime();
if (response.code === 0) {
setRuntimeStatus(response.data.status);
syncRuntimeStatus(response.data.status, 'runtime_install');
const installFinished =
response.data.status.status === 'installed' &&
response.data.status.installing !== true &&
@@ -260,7 +734,7 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
installFinished ? t('picoclaw.install.success') : t('picoclaw.install.installing')
)
]);
await refreshState();
await refreshState({ force: true, allowDuringMutation: true });
return;
}
@@ -290,6 +764,7 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
]);
} finally {
setIsInstallRequestPending(false);
endMutation('runtime_install');
}
}
@@ -308,6 +783,10 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
return;
}
if (!beginMutation('model_save')) {
return;
}
setIsSavingModelConfig(true);
try {
const response = await setPicoclawModelConfig({
@@ -316,10 +795,10 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
api_key: apiKey
});
if (response.code === 0) {
setRuntimeStatus(response.data.status);
syncRuntimeStatus(response.data.status, 'model_config');
setIsModelConfigOpen(false);
setMessages((current) => [...current, createStatusMessage(t('picoclaw.model.saved'))]);
await refreshState();
await refreshState({ force: true, allowDuringMutation: true });
return;
}
@@ -347,6 +826,7 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
]);
} finally {
setIsSavingModelConfig(false);
endMutation('model_save');
}
}
@@ -355,13 +835,17 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
return;
}
if (!beginMutation('agent_switch')) {
return;
}
setIsSwitchingAgent(true);
try {
const response = await setPicoclawAgentProfile({ profile });
if (response.code === 0) {
setRuntimeStatus(response.data.status);
syncRuntimeStatus(response.data.status, 'agent_profile');
setMessages((current) => [...current, createStatusMessage(t('picoclaw.agent.switched'))]);
await refreshState();
await refreshState({ force: true, allowDuringMutation: true });
if (response.data.status?.ready === true && transportState !== 'connected') {
try {
@@ -398,22 +882,27 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
]);
} finally {
setIsSwitchingAgent(false);
endMutation('agent_switch');
}
}
async function handleUninstallRuntime() {
if (!beginMutation('runtime_uninstall')) {
return;
}
setIsUninstallRequestPending(true);
setMessages((current) => [...current, createStatusMessage(t('picoclaw.install.uninstalling'))]);
try {
const response = await uninstallRuntime();
if (response.code === 0) {
setRuntimeStatus(response.data.status);
syncRuntimeStatus(response.data.status, 'runtime_uninstall');
setMessages((current) => [
...current,
createStatusMessage(t('picoclaw.install.uninstalled'))
]);
await refreshState();
await refreshState({ force: true, allowDuringMutation: true });
return;
}
@@ -442,6 +931,7 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
]);
} finally {
setIsUninstallRequestPending(false);
endMutation('runtime_uninstall');
}
}
@@ -449,6 +939,8 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio
refreshState,
handleStartRuntime,
handleInstallRuntime,
handleGrantControl,
handleReleaseControl,
handleSaveModelConfig,
handleAgentProfileChange,
handleUninstallRuntime

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { TFunction } from 'i18next';
@@ -8,6 +8,7 @@ import {
setPicoclawRuntimeInstallSnapshot,
type PicoclawRuntimeInstallSnapshot
} from '@/lib/picoclaw-storage.ts';
import { normalizeAIControlStatus, type AIControlStatus } from '@/jotai/ai-control.ts';
import type {
PicoclawChatMessage,
PicoclawOverlayState,
@@ -17,6 +18,7 @@ import type {
PicoclawTransportState
} from '@/jotai/picoclaw.ts';
import { PICOCLAW_INPUT_KEYBOARD_LOCK_SOURCE, releasePicoclawInputFocus } from './keyboard-lock.ts';
import {
createAssistantMessage,
createErrorMessage,
@@ -27,23 +29,39 @@ import {
retainLatestObservationScreenshot
} from './message-utils.ts';
import { canConnectGateway, isPicoclawRuntimeInstalling } from './runtime-view.ts';
import type { PicoclawRefreshOptions } from './sidebar-actions.ts';
type MessageSetter = Dispatch<SetStateAction<PicoclawChatMessage[]>>;
type TakeoverSetter = Dispatch<SetStateAction<PicoclawTakeoverState>>;
type AIControlStatusSetter = Dispatch<SetStateAction<AIControlStatus | null>>;
type KeyboardLockSetter = (action: { source: string; locked: boolean }) => void;
const STATUS_CONFIRM_TIMEOUT_MS = 10_000;
const STATUS_CONFIRM_INTERVAL_MS = 500;
const STATUS_REFRESH_INTERVAL_MS = 3000;
type GatewayEventOptions = {
t: TFunction;
refreshStateRef: MutableRefObject<
(options?: PicoclawRefreshOptions) => Promise<PicoclawRuntimeStatus | null>
>;
setActiveSessionId: Dispatch<SetStateAction<string>>;
setTakeover: TakeoverSetter;
setMessages: MessageSetter;
setTransportState: Dispatch<SetStateAction<PicoclawTransportState>>;
setOverlay: Dispatch<SetStateAction<PicoclawOverlayState>>;
setRunState: Dispatch<SetStateAction<PicoclawRunState>>;
setRuntimeStatus: Dispatch<SetStateAction<PicoclawRuntimeStatus | null>>;
setAIControlStatus: AIControlStatusSetter;
setKeyboardLock: KeyboardLockSetter;
isTogglingRuntimeRef: MutableRefObject<boolean>;
};
type LifecycleOptions = {
t: TFunction;
refreshStateRef: MutableRefObject<() => Promise<PicoclawRuntimeStatus | null>>;
refreshStateRef: MutableRefObject<
(options?: PicoclawRefreshOptions) => Promise<PicoclawRuntimeStatus | null>
>;
setActiveSessionId: Dispatch<SetStateAction<string>>;
setIsFreshConversation: Dispatch<SetStateAction<boolean>>;
setMessages: MessageSetter;
@@ -62,16 +80,91 @@ type InstallSnapshotOptions = {
setMessages: MessageSetter;
};
type GatewayAutoConnectOptions = {
activeSessionId: string;
runtimeStatus: PicoclawRuntimeStatus | null;
transportState: PicoclawTransportState;
disabled?: boolean;
};
export function usePicoclawGatewayEvents({
t,
refreshStateRef,
setActiveSessionId,
setTakeover,
setMessages,
setTransportState,
setOverlay,
setRunState
setRunState,
setRuntimeStatus,
setAIControlStatus,
setKeyboardLock,
isTogglingRuntimeRef
}: GatewayEventOptions) {
useEffect(() => {
let disposed = false;
function syncAIControlStatus(value: unknown, source: string) {
const nextControlStatus = normalizeAIControlStatus(value, source);
if (nextControlStatus) {
setAIControlStatus(nextControlStatus);
}
}
async function confirmRuntimeStatus(source: string) {
const deadline = Date.now() + STATUS_CONFIRM_TIMEOUT_MS;
while (!disposed && Date.now() < deadline) {
const status = await refreshStateRef.current();
if (status && status.transitioning !== true) {
syncAIControlStatus(status, source);
return status;
}
await new Promise((resolve) => window.setTimeout(resolve, STATUS_CONFIRM_INTERVAL_MS));
}
return null;
}
function clearActiveGatewayUI(reason: string) {
releasePicoclawInputFocus();
setKeyboardLock({ source: PICOCLAW_INPUT_KEYBOARD_LOCK_SOURCE, locked: false });
setTakeover((current) => ({
...current,
active: false,
reason
}));
setOverlay(HIDDEN_OVERLAY);
setTransportState('disconnected');
setRunState('idle');
}
function clearDeviceControlUI(reason: string) {
releasePicoclawInputFocus();
setKeyboardLock({ source: PICOCLAW_INPUT_KEYBOARD_LOCK_SOURCE, locked: false });
setTakeover((current) => ({
...current,
active: false,
reason
}));
setOverlay(HIDDEN_OVERLAY);
setRunState('idle');
}
function markRuntimeTransportUnavailable(reason: string) {
releasePicoclawInputFocus();
setKeyboardLock({ source: PICOCLAW_INPUT_KEYBOARD_LOCK_SOURCE, locked: false });
setTakeover((current) => ({
...current,
active: false,
reason
}));
setOverlay(HIDDEN_OVERLAY);
setRunState('idle');
void refreshStateRef.current({
force: true,
preserveRuntimeOnError: true
});
}
const unsubs = [
picoclawGateway.on('connected', ({ sessionId }) => {
setActiveSessionId(sessionId);
@@ -95,6 +188,11 @@ export function usePicoclawGatewayEvents({
return;
}
if (state === 'error') {
markRuntimeTransportUnavailable('transport_error');
return;
}
setTakeover((current) => ({
...current,
active: false,
@@ -138,7 +236,108 @@ export function usePicoclawGatewayEvents({
picoclawGateway.on('error', (error) => {
setMessages((current) => [...current, createErrorMessage(error)]);
}),
picoclawGateway.on('control_mode_changed', (control) => {
syncAIControlStatus(control, control.source ?? 'gateway');
setRuntimeStatus((current) =>
current
? {
...current,
control_mode: control.mode,
transitioning: control.transitioning,
control,
capabilities: {
chat: current.capabilities?.chat ?? current.ready === true,
read_only_tools: current.capabilities?.read_only_tools ?? current.ready === true,
device_write: control.can_control
}
}
: current
);
if (!control.can_control) {
if (control.mode === 'mcp') {
clearActiveGatewayUI('control_mcp');
} else {
clearDeviceControlUI('control_released');
}
}
}),
picoclawGateway.on('close', (closeEvent) => {
if (closeEvent.code === 4006) {
clearActiveGatewayUI('control_mode_switched');
setAIControlStatus((current) =>
current
? {
...current,
transitioning: true,
canControlPicoclaw: false,
source: 'gateway_close'
}
: current
);
setRuntimeStatus((current) =>
current
? {
...current,
transitioning: true
}
: current
);
void confirmRuntimeStatus('gateway_close').then((status) => {
if (disposed || status?.control_mode !== 'mcp') return;
setMessages((current) => [
...current,
createStatusMessage(t('picoclaw.status.controlSwitchedToMCP'))
]);
});
return;
}
if (closeEvent.code === 4007) {
clearActiveGatewayUI('runtime_stopped');
setAIControlStatus((current) =>
current
? {
...current,
mode: current.mode === 'picoclaw' ? 'off' : current.mode,
transitioning: false,
canControlPicoclaw: false,
source: 'gateway_runtime_stopped'
}
: current
);
setRuntimeStatus((current) =>
current
? {
...current,
ready: false,
status: 'stopped',
current_session: '',
control_mode: current.control_mode === 'picoclaw' ? 'off' : current.control_mode,
transitioning: false,
control: current.control
? {
...current.control,
mode: current.control.mode === 'picoclaw' ? 'off' : current.control.mode,
transitioning: false,
can_control: false
}
: current.control,
capabilities: {
chat: false,
read_only_tools: false,
device_write: false
}
}
: current
);
void confirmRuntimeStatus('gateway_runtime_stopped');
if (!isTogglingRuntimeRef.current) {
setMessages((current) => [
...current,
createStatusMessage(t('picoclaw.status.runtimeStopped'))
]);
}
return;
}
if (closeEvent.code === 1000) {
setMessages((current) => [
...current,
@@ -149,9 +348,23 @@ export function usePicoclawGatewayEvents({
];
return () => {
disposed = true;
unsubs.forEach((unsubscribe) => unsubscribe());
};
}, [setActiveSessionId, setMessages, setOverlay, setRunState, setTakeover, setTransportState, t]);
}, [
setActiveSessionId,
setMessages,
setOverlay,
setRunState,
setRuntimeStatus,
setAIControlStatus,
setKeyboardLock,
setTakeover,
setTransportState,
isTogglingRuntimeRef,
t,
refreshStateRef
]);
}
export function usePicoclawSidebarLifecycle({
@@ -213,7 +426,7 @@ export function usePicoclawSidebarLifecycle({
createStatusMessage(t('picoclaw.status.connecting'))
]);
try {
await connectGateway();
await connectGateway(nextSessionId);
} catch {
// handled by gateway events
}
@@ -254,7 +467,9 @@ export function usePicoclawSidebarLifecycle({
export function usePicoclawInstallRefresh(
isRuntimeInstallActive: boolean,
refreshStateRef: MutableRefObject<() => Promise<PicoclawRuntimeStatus | null>>
refreshStateRef: MutableRefObject<
(options?: PicoclawRefreshOptions) => Promise<PicoclawRuntimeStatus | null>
>
) {
useEffect(() => {
if (!isRuntimeInstallActive) {
@@ -271,6 +486,55 @@ export function usePicoclawInstallRefresh(
}, [isRuntimeInstallActive, refreshStateRef]);
}
export function usePicoclawGatewayAutoConnect({
activeSessionId,
runtimeStatus,
transportState,
disabled
}: GatewayAutoConnectOptions) {
const lastAttemptAtRef = useRef(0);
useEffect(() => {
if (
disabled ||
!canConnectGateway(runtimeStatus) ||
transportState === 'connected' ||
transportState === 'connecting'
) {
return;
}
const now = Date.now();
if (now - lastAttemptAtRef.current < STATUS_REFRESH_INTERVAL_MS) {
return;
}
lastAttemptAtRef.current = now;
void connectGateway(activeSessionId || undefined).catch(() => undefined);
}, [activeSessionId, runtimeStatus, transportState, disabled]);
}
export function usePicoclawStatusRefresh(
disabled: boolean,
refreshStateRef: MutableRefObject<
(options?: PicoclawRefreshOptions) => Promise<PicoclawRuntimeStatus | null>
>
) {
useEffect(() => {
if (disabled) {
return;
}
const timer = window.setInterval(() => {
void refreshStateRef.current();
}, STATUS_REFRESH_INTERVAL_MS);
return () => {
window.clearInterval(timer);
};
}, [disabled, refreshStateRef]);
}
export function usePicoclawInstallSnapshotSync({
t,
runtimeStatus,

View File

@@ -18,9 +18,12 @@ type SidebarHeaderProps = {
modelConfigured?: boolean;
runtimeReady?: boolean;
isTogglingRuntime: boolean;
actionDisabled?: boolean;
runtimeActionDisabled?: boolean;
agentProfile?: string;
isSwitchingAgent?: boolean;
isHistoryOpen?: boolean;
runtimeToggleTitle?: string;
onToggleRuntime: () => void | Promise<void>;
onClose?: () => void;
onOpenHistory?: () => void;
@@ -36,9 +39,12 @@ export const SidebarHeader = ({
modelConfigured,
runtimeReady,
isTogglingRuntime,
actionDisabled,
runtimeActionDisabled,
agentProfile,
isSwitchingAgent,
isHistoryOpen,
runtimeToggleTitle,
onToggleRuntime,
onClose,
onOpenHistory,
@@ -52,6 +58,8 @@ export const SidebarHeader = ({
const isRuntimeReady = runtimeReady === true;
const isInstalled = installed !== false;
const isModelConfigured = modelConfigured !== false;
const areActionsDisabled = actionDisabled === true;
const isRuntimeActionDisabled = runtimeActionDisabled === true || areActionsDisabled;
const agentOptions = [
{
@@ -79,7 +87,7 @@ export const SidebarHeader = ({
label: <span className="text-xs">{t('picoclaw.model.menuLabel')}</span>,
icon: <SlidersHorizontalIcon size={14} className="text-neutral-400" />,
onClick: () => onOpenModelConfig?.(),
disabled: isUninstallingRuntime || isTogglingRuntime || !isInstalled
disabled: areActionsDisabled || isUninstallingRuntime || isTogglingRuntime || !isInstalled
},
{
type: 'divider'
@@ -90,7 +98,7 @@ export const SidebarHeader = ({
label: <span className="text-xs">{t('picoclaw.uninstall.menuLabel')}</span>,
icon: <TrashIcon size={14} />,
onClick: handleUninstallClick,
disabled: isUninstallingRuntime || isTogglingRuntime
disabled: areActionsDisabled || isUninstallingRuntime || isTogglingRuntime
}
];
@@ -120,7 +128,9 @@ export const SidebarHeader = ({
size="small"
value={agentProfile || 'kvm'}
onChange={(value) => void onAgentProfileChange?.(value)}
disabled={isUninstallingRuntime || isTogglingRuntime || isSwitchingAgent}
disabled={
areActionsDisabled || isUninstallingRuntime || isTogglingRuntime || isSwitchingAgent
}
loading={isSwitchingAgent}
popupMatchSelectWidth={false}
className="min-w-[120px] text-xs [&_.ant-select-selection-item]:text-xs [&_.ant-select-selection-item]:text-neutral-300"
@@ -146,6 +156,7 @@ export const SidebarHeader = ({
{isRuntimeReady ? (
<>
<Button
disabled={areActionsDisabled}
type="text"
size="small"
onClick={onOpenHistory}
@@ -159,10 +170,10 @@ export const SidebarHeader = ({
icon={<HistoryIcon size={14} />}
/>
<Button
disabled={isTogglingRuntime}
disabled={isRuntimeActionDisabled || isTogglingRuntime}
loading={isTogglingRuntime}
onClick={() => void onToggleRuntime()}
title={t('picoclaw.config.stopRuntime')}
title={runtimeToggleTitle || t('picoclaw.config.stopRuntime')}
icon={!isTogglingRuntime ? <PowerIcon size={14} /> : undefined}
type="text"
size="small"
@@ -171,10 +182,10 @@ export const SidebarHeader = ({
</>
) : isModelConfigured ? (
<Button
disabled={isTogglingRuntime}
disabled={isRuntimeActionDisabled || isTogglingRuntime}
loading={isTogglingRuntime}
onClick={() => void onToggleRuntime()}
title={t('picoclaw.config.startRuntime')}
title={runtimeToggleTitle || t('picoclaw.config.startRuntime')}
icon={!isTogglingRuntime ? <PlayIcon size={14} /> : undefined}
type="text"
size="small"
@@ -187,11 +198,11 @@ export const SidebarHeader = ({
menu={{ items: moreMenuItems }}
trigger={['click']}
placement="bottomRight"
disabled={isUninstallingRuntime || isTogglingRuntime}
disabled={areActionsDisabled || isUninstallingRuntime || isTogglingRuntime}
arrow={{ pointAtCenter: true }}
>
<Button
disabled={isUninstallingRuntime}
disabled={areActionsDisabled || isUninstallingRuntime}
loading={isUninstallingRuntime}
icon={!isUninstallingRuntime ? <EllipsisIcon size={16} /> : undefined}
type="text"

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
type SidebarInstallProps = {
installProgress?: number;
installStage?: string;
disabled?: boolean;
isInstalling: boolean;
onInstall: () => void | Promise<void>;
};
@@ -12,6 +13,7 @@ type SidebarInstallProps = {
export const SidebarInstall = ({
installProgress,
installStage,
disabled,
isInstalling,
onInstall
}: SidebarInstallProps) => {
@@ -54,6 +56,7 @@ export const SidebarInstall = ({
{!isInstalling && (
<Button
icon={<DownloadIcon size={13} />}
disabled={disabled}
onClick={() => void onInstall()}
type="primary"
>

View File

@@ -1,14 +1,24 @@
import { useEffect } from 'react';
import { Button, Input } from 'antd';
import { useSetAtom } from 'jotai';
import { BookOpenIcon, CpuIcon, ExternalLinkIcon, KeyRoundIcon, LinkIcon, SaveIcon } from 'lucide-react';
import {
BookOpenIcon,
CpuIcon,
ExternalLinkIcon,
KeyRoundIcon,
LinkIcon,
SaveIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { keyboardLockAtom } from '@/jotai/keyboard.ts';
import { PICOCLAW_MODEL_CONFIG_KEYBOARD_LOCK_SOURCE } from './keyboard-lock.ts';
type SidebarModelConfigProps = {
apiBase: string;
apiKey: string;
disabled?: boolean;
isSaving: boolean;
modelIdentifier: string;
modelName?: string;
@@ -23,6 +33,7 @@ type SidebarModelConfigProps = {
export const SidebarModelConfig = ({
apiBase,
apiKey,
disabled,
isSaving,
modelIdentifier,
modelName,
@@ -34,14 +45,14 @@ export const SidebarModelConfig = ({
showCancel = false
}: SidebarModelConfigProps) => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setKeyboardLock = useSetAtom(keyboardLockAtom);
useEffect(() => {
setIsKeyboardEnable(false);
setKeyboardLock({ source: PICOCLAW_MODEL_CONFIG_KEYBOARD_LOCK_SOURCE, locked: true });
return () => {
setIsKeyboardEnable(true);
setKeyboardLock({ source: PICOCLAW_MODEL_CONFIG_KEYBOARD_LOCK_SOURCE, locked: false });
};
}, [setIsKeyboardEnable]);
}, [setKeyboardLock]);
return (
<div className="flex flex-1 flex-col overflow-y-auto px-5 pb-8 pt-10">
@@ -74,7 +85,10 @@ export const SidebarModelConfig = ({
</div>
</div>
</div>
<ExternalLinkIcon size={14} className="text-neutral-500 transition-colors group-hover:text-neutral-300" />
<ExternalLinkIcon
size={14}
className="text-neutral-500 transition-colors group-hover:text-neutral-300"
/>
</a>
{/* Fields */}
@@ -116,10 +130,15 @@ export const SidebarModelConfig = ({
{/* Save */}
<div className="mt-6 flex justify-end gap-2">
{showCancel && <Button onClick={onCancel}>{t('picoclaw.cancel')}</Button>}
{showCancel && (
<Button disabled={disabled || isSaving} onClick={onCancel}>
{t('picoclaw.cancel')}
</Button>
)}
<Button
icon={<SaveIcon size={13} />}
loading={isSaving}
disabled={disabled}
onClick={() => void onSave()}
type="primary"
>

View File

@@ -25,6 +25,7 @@ import type {
import { createErrorMessage, createStatusMessage, HIDDEN_OVERLAY } from './message-utils.ts';
import { canConnectGateway } from './runtime-view.ts';
import type { PicoclawRefreshOptions } from './sidebar-actions.ts';
type RuntimeStatusSetter = Dispatch<SetStateAction<PicoclawRuntimeStatus | null>>;
type MessageSetter = Dispatch<SetStateAction<PicoclawChatMessage[]>>;
@@ -37,8 +38,9 @@ type PicoclawSidebarSessionActionOptions = {
config: PicoclawConfigState;
activeSessionId: string;
isFreshConversation: boolean;
isSidebarMutationPending: boolean;
isSwitchingSession: boolean;
refreshState: () => Promise<PicoclawRuntimeStatus | null>;
refreshState: (options?: PicoclawRefreshOptions) => Promise<PicoclawRuntimeStatus | null>;
setMessages: MessageSetter;
setTakeover: TakeoverSetter;
setOverlay: Dispatch<SetStateAction<PicoclawOverlayState>>;
@@ -63,6 +65,7 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
config,
activeSessionId,
isFreshConversation,
isSidebarMutationPending,
isSwitchingSession,
refreshState,
setMessages,
@@ -96,16 +99,20 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
}
async function handleSend(content: string) {
if (isSidebarMutationPending) {
return false;
}
let ready = transportState === 'connected' && runState === 'idle';
if (!ready) {
const nextRuntimeStatus = await refreshState();
const nextRuntimeStatus = await refreshState({ force: true, preserveRuntimeOnError: true });
if (!canConnectGateway(nextRuntimeStatus ?? null)) {
return;
return false;
}
try {
await connectGateway();
await connectGateway(activeSessionId || undefined);
ready = true;
} catch {
ready = false;
@@ -113,7 +120,7 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
}
if (!ready) {
return;
return false;
}
const id = generateUUIDv4();
@@ -128,15 +135,47 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
}
]);
sendChatMessage(content, {
const sent = sendChatMessage(content, {
id,
maxSteps: config.maxSteps,
maxRuntimeMs: config.maxRuntimeMs
});
return sent !== null;
}
async function handleReconnectGateway() {
if (isSidebarMutationPending) {
return false;
}
if (transportState === 'connected' || transportState === 'connecting') {
return true;
}
const nextRuntimeStatus = await refreshState({ force: true, preserveRuntimeOnError: true });
if (!canConnectGateway(nextRuntimeStatus ?? null)) {
setMessages((current) => [
...current,
createErrorMessage({
code: 'GATEWAY_RECONNECT_BLOCKED',
message: t('picoclaw.connection.transport.reconnectBlocked', {
defaultValue: 'PicoClaw needs device control before reconnecting.'
}),
raw: nextRuntimeStatus
})
]);
return false;
}
try {
await connectGateway(activeSessionId || undefined);
return true;
} catch {
return false;
}
}
async function handleNewConversation() {
if (isFreshConversation) {
if (isFreshConversation || isSidebarMutationPending) {
return;
}
@@ -158,7 +197,7 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
setIsFreshConversation(true);
await closePromise;
const nextRuntimeStatus = await refreshState();
const nextRuntimeStatus = await refreshState({ force: true, preserveRuntimeOnError: true });
if (!canConnectGateway(nextRuntimeStatus ?? null)) {
return;
}
@@ -187,7 +226,7 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
}
async function handleSelectHistorySession(sessionId: string) {
if (isSwitchingSession) {
if (isSwitchingSession || isSidebarMutationPending) {
return;
}
@@ -239,7 +278,7 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
setRunState('idle');
await closePromise;
const nextRuntimeStatus = await refreshState();
const nextRuntimeStatus = await refreshState({ force: true, preserveRuntimeOnError: true });
setRuntimeStatus((current) =>
current
? {
@@ -313,6 +352,7 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
return {
handleSend,
handleReconnectGateway,
handleNewConversation,
handleStop,
handleOpenHistory,

View File

@@ -1,12 +1,21 @@
import { Button } from 'antd';
import { Button, message, Modal } from 'antd';
import { useSetAtom } from 'jotai';
import { Loader2Icon, PlayIcon } from 'lucide-react';
import {
Loader2Icon,
PlayIcon,
RefreshCwIcon,
ShieldCheckIcon,
ShieldOffIcon,
WifiOffIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { closeGateway } from '@/api/picoclaw.ts';
import { picoclawChatOpenAtom } from '@/jotai/picoclaw.ts';
import { MessageInput } from './message-input.tsx';
import { MessageList } from './message-list.tsx';
import { canConnectGateway, derivePicoclawControlViewModel } from './runtime-view.ts';
import { SidebarHeader } from './sidebar-header.tsx';
import { SidebarHistory } from './sidebar-history.tsx';
import { SidebarInstall } from './sidebar-install.tsx';
@@ -15,6 +24,7 @@ import { useSidebar } from './use-sidebar.ts';
export const Sidebar = () => {
const { t } = useTranslation();
const [modal, contextHolder] = Modal.useModal();
const setIsChatOpen = useSetAtom(picoclawChatOpenAtom);
const {
connectionLabel,
@@ -30,9 +40,11 @@ export const Sidebar = () => {
handleNewConversation,
handleOpenHistory,
handleOpenModelConfig,
handleReconnectGateway,
handleSaveModelConfig,
handleSelectHistorySession,
historySessions,
aiControlStatus,
activeSessionId,
isDeletingSession,
isFreshConversation,
@@ -44,6 +56,10 @@ export const Sidebar = () => {
installStage,
isSavingModelConfig,
isSwitchingAgent,
isControlMutationPending,
isRuntimeMutationPending,
isSidebarMutationPending,
isReleasingControl,
modelApiBase,
modelApiKey,
modelIdentifier,
@@ -57,22 +73,207 @@ export const Sidebar = () => {
isUninstallingRuntime,
handleUninstallRuntime,
handleSend,
handleStartRuntime
handleGrantControl,
handleReleaseControl,
handleStartRuntime,
refreshRuntimeState
} = useSidebar();
const runtimeError = runtimeStatus?.last_error || runtimeStatus?.config_error;
const controlView = derivePicoclawControlViewModel({
aiControlStatus,
runtimeStatus,
isReleasingControl,
t
});
const isRuntimeLifecyclePending =
runtimeStatus?.restoring === true ||
runtimeStatus?.status === 'starting' ||
runtimeStatus?.status === 'restoring' ||
runtimeStatus?.status === 'stopping';
const controlActionPending = controlView.isActionPending || isControlMutationPending;
const actionBusy =
isSidebarMutationPending || controlView.isTransitioning || isRuntimeLifecyclePending;
const canUseGateway = canConnectGateway(runtimeStatus);
const isGatewayConnected = canUseGateway && transportState === 'connected';
const isGatewayConnecting = canUseGateway && transportState === 'connecting';
const runtimeActionDisabled =
actionBusy ||
isSavingModelConfig ||
isSwitchingAgent ||
isUninstallingRuntime ||
isInstallingRuntime;
const controlIconClass =
controlView.severity === 'success'
? 'text-emerald-400'
: controlView.severity === 'warning'
? 'text-amber-400'
: controlView.severity === 'info'
? 'text-sky-400'
: 'text-neutral-400';
const runtimeStartButtonLabel =
controlView.mode === 'mcp'
? t('picoclaw.start.switchFromMCP')
: controlView.mode === 'off'
? t('picoclaw.start.takeoverAndStart')
: t('picoclaw.config.startRuntime');
async function startRuntimeWithControl(latestRuntimeStatus: typeof runtimeStatus) {
if (runtimeActionDisabled) {
return;
}
const latestControlMode =
latestRuntimeStatus?.control?.mode ?? latestRuntimeStatus?.control_mode ?? controlView.mode;
if (latestControlMode !== 'picoclaw') {
const granted = await handleGrantControl();
if (!granted) {
return;
}
}
await handleStartRuntime();
}
const handleToggleRuntime = async () => {
if (runtimeActionDisabled) {
return;
}
if (runtimeStatus?.ready) {
return handleStartRuntime();
}
const latestRuntimeStatus = await refreshRuntimeState({ force: true });
const effectiveRuntimeStatus = latestRuntimeStatus ?? runtimeStatus;
if (!effectiveRuntimeStatus) {
message.error(t('picoclaw.status.runtimeStartFailed'));
return;
}
if (effectiveRuntimeStatus.ready) return;
const latestControlMode =
effectiveRuntimeStatus.control?.mode ??
effectiveRuntimeStatus.control_mode ??
controlView.mode;
if (latestControlMode === 'mcp') {
modal.confirm({
title: t('picoclaw.start.enableConfirmTitle'),
content: t('picoclaw.start.enableConfirmDesc'),
okText: t('picoclaw.start.enableConfirmOk'),
cancelText: t('picoclaw.start.enableConfirmCancel'),
onOk: () => startRuntimeWithControl(effectiveRuntimeStatus)
});
return;
}
return startRuntimeWithControl(effectiveRuntimeStatus);
};
const handleToggleControl = async () => {
if (
isRuntimeMutationPending ||
isSavingModelConfig ||
isSwitchingAgent ||
isUninstallingRuntime
) {
return;
}
if (controlView.primaryAction === 'release') {
await handleReleaseControl();
return;
}
if (controlView.primaryAction !== 'grant') {
return;
}
if (controlView.mode !== 'mcp') {
await handleGrantControl();
return;
}
modal.confirm({
title: t('picoclaw.control.grantConfirmTitle', {
defaultValue: 'Switch device control to PicoClaw?'
}),
content: t('picoclaw.control.grantConfirmDesc', {
defaultValue: 'External MCP device writes will be interrupted.'
}),
okText: t('picoclaw.control.grant', { defaultValue: 'Grant control' }),
cancelText: t('picoclaw.start.enableConfirmCancel'),
onOk: handleGrantControl
});
};
const handleCloseSidebar = () => {
void closeGateway();
setIsChatOpen(false);
};
const runtimeHasStarted = runtimeStatus?.ready === true || runtimeStatus?.status === 'ready';
const shouldShowControlStatusBar =
(runtimeHasStarted || isReleasingControl) && sidebarMode !== 'loading';
const controlStatusBar =
runtimeStatus && shouldShowControlStatusBar ? (
<div className="flex flex-shrink-0 items-center justify-between gap-3 border-b border-white/[0.06] px-3 py-2.5">
<div className="min-w-0 text-xs">
<div className="flex min-w-0 items-center gap-2 text-neutral-300">
{controlView.isTransitioning ? (
<Loader2Icon size={14} className={`shrink-0 animate-spin ${controlIconClass}`} />
) : controlView.canDeviceWrite ? (
<ShieldCheckIcon size={14} className={`shrink-0 ${controlIconClass}`} />
) : (
<ShieldOffIcon size={14} className={`shrink-0 ${controlIconClass}`} />
)}
<span className="truncate font-medium">{controlView.label}</span>
</div>
<div className="mt-0.5 line-clamp-2 max-w-[240px] leading-4 text-neutral-500">
{controlView.description}
</div>
</div>
{controlView.primaryAction !== 'none' || controlView.isActionPending ? (
<Button
size="small"
type={controlView.canDeviceWrite ? 'default' : 'primary'}
loading={controlActionPending}
disabled={
controlView.isTransitioning ||
isRuntimeMutationPending ||
isSavingModelConfig ||
isSwitchingAgent ||
isUninstallingRuntime
}
onClick={() => void handleToggleControl()}
className="min-w-[92px] shrink-0"
>
{controlView.primaryActionLabel}
</Button>
) : (
<Button size="small" disabled className="min-w-[92px] shrink-0">
{controlView.primaryActionLabel}
</Button>
)}
</div>
) : null;
return (
<aside className="picoclaw-sidebar-scrollbar flex h-full min-h-0 w-full flex-col overflow-x-hidden bg-[#0d0d0f]">
<aside
data-picoclaw-sidebar="true"
className="picoclaw-sidebar-scrollbar flex h-full min-h-0 w-full flex-col overflow-x-hidden bg-[#0d0d0f]"
>
{contextHolder}
{/* Header */}
<SidebarHeader
isCheckingRuntime={isInitializing}
installed={runtimeStatus?.installed}
modelConfigured={runtimeStatus?.model_configured}
isTogglingRuntime={isTogglingRuntime}
actionDisabled={actionBusy}
runtimeActionDisabled={runtimeActionDisabled}
agentProfile={runtimeStatus?.agent_profile}
isSwitchingAgent={isSwitchingAgent}
runtimeReady={runtimeStatus?.ready}
onToggleRuntime={handleStartRuntime}
onClose={() => setIsChatOpen(false)}
runtimeToggleTitle={
runtimeStatus?.ready ? t('picoclaw.config.stopRuntime') : runtimeStartButtonLabel
}
onToggleRuntime={handleToggleRuntime}
onClose={handleCloseSidebar}
isHistoryOpen={isHistoryOpen}
onOpenHistory={isHistoryOpen ? handleCloseHistory : handleOpenHistory}
onAgentProfileChange={handleAgentProfileChange}
@@ -83,6 +284,7 @@ export const Sidebar = () => {
{/* Divider */}
<div className="h-px bg-white/[0.06]" />
{controlStatusBar}
{/* Body */}
<div className="flex min-h-0 flex-1 flex-col">
@@ -97,6 +299,7 @@ export const Sidebar = () => {
<SidebarInstall
installProgress={installProgress}
installStage={installStage}
disabled={actionBusy}
isInstalling={isInstallingRuntime}
onInstall={handleInstallRuntime}
/>
@@ -104,6 +307,7 @@ export const Sidebar = () => {
<SidebarModelConfig
apiBase={modelApiBase}
apiKey={modelApiKey}
disabled={actionBusy}
isSaving={isSavingModelConfig}
modelIdentifier={modelIdentifier}
modelName={runtimeStatus?.model_name}
@@ -120,43 +324,108 @@ export const Sidebar = () => {
activeSessionId={activeSessionId}
isLoading={isLoadingHistory}
isDeleting={isDeletingSession}
isSwitching={isSwitchingSession}
isSwitching={isSwitchingSession || actionBusy}
onSelect={handleSelectHistorySession}
onDelete={handleDeleteHistorySession}
/>
) : !runtimeStatus?.ready ? (
<div className="flex flex-1 flex-col items-center px-6 pb-6 pt-10 text-center">
<div className="flex flex-col items-center gap-4">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/[0.08] bg-white/[0.04]">
<PlayIcon className="text-sky-400" size={22} />
</div>
<div>
<h3 className="mb-1 text-sm font-medium text-neutral-200">
{t('picoclaw.start.title')}
</h3>
<p className="text-xs text-neutral-500">{t('picoclaw.start.description')}</p>
</div>
<Button
type="primary"
icon={<PlayIcon size={14} />}
loading={isTogglingRuntime}
onClick={() => void handleStartRuntime()}
>
{t('picoclaw.config.startRuntime')}
</Button>
</div>
</div>
) : (
<>
<MessageList messages={messages} runState={runState} />
<div className="border-t border-white/[0.06] px-3 pb-3 pt-3">
<MessageInput
transportState={transportState}
onSend={handleSend}
onNewConversation={handleNewConversation}
disableNewConversation={isFreshConversation}
/>
</div>
{!runtimeStatus?.ready ? (
<div className="flex flex-1 flex-col items-center px-6 pb-6 pt-10 text-center">
<div className="flex flex-col items-center gap-4">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/[0.08] bg-white/[0.04]">
<PlayIcon className="text-sky-400" size={22} />
</div>
<div>
<h3 className="mb-1 text-sm font-medium text-neutral-200">
{runtimeStatus?.restoring
? t('picoclaw.connection.runtime.restoring')
: t('picoclaw.start.title')}
</h3>
<p className="text-xs text-neutral-500">{t('picoclaw.start.description')}</p>
{!isInitializing && (
<p className="mt-2 text-xs text-neutral-400">{connectionLabel}</p>
)}
{(runtimeError || runtimeStatus?.runtime_intent?.last_error) && (
<p className="mt-2 max-w-[280px] break-words text-xs leading-5 text-red-400">
{runtimeError || runtimeStatus?.runtime_intent?.last_error}
</p>
)}
</div>
<Button
type="primary"
icon={<PlayIcon size={14} />}
loading={isTogglingRuntime || isRuntimeLifecyclePending}
disabled={runtimeActionDisabled}
onClick={() => void handleToggleRuntime()}
>
{runtimeStartButtonLabel}
</Button>
</div>
</div>
) : !canUseGateway ? (
<div className="flex flex-1 flex-col items-center px-6 pb-6 pt-10 text-center">
<div className="flex flex-col items-center gap-4">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/[0.08] bg-white/[0.04]">
<ShieldOffIcon className="text-amber-400" size={22} />
</div>
<div>
<h3 className="mb-1 text-sm font-medium text-neutral-200">{connectionLabel}</h3>
<p className="max-w-[280px] text-xs leading-5 text-neutral-500">
{controlView.mode === 'mcp'
? t('picoclaw.connection.runtime.readyBlockedByMCP', {
defaultValue:
'The runtime is running, but external MCP currently controls device input.'
})
: t('picoclaw.connection.runtime.readyWithoutControl', {
defaultValue:
'The runtime is running. Grant PicoClaw device control before reconnecting.'
})}
</p>
</div>
</div>
</div>
) : !isGatewayConnected ? (
<div className="flex flex-1 flex-col items-center px-6 pb-6 pt-10 text-center">
<div className="flex flex-col items-center gap-4">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/[0.08] bg-white/[0.04]">
{isGatewayConnecting ? (
<Loader2Icon className="animate-spin text-sky-400" size={22} />
) : (
<WifiOffIcon className="text-amber-400" size={22} />
)}
</div>
<div>
<h3 className="mb-1 text-sm font-medium text-neutral-200">{connectionLabel}</h3>
<p className="text-xs text-neutral-500">
{t('picoclaw.connection.transport.reconnectDescription')}
</p>
</div>
<Button
type="primary"
icon={<RefreshCwIcon size={14} />}
loading={isGatewayConnecting}
disabled={isGatewayConnecting || actionBusy}
onClick={() => void handleReconnectGateway()}
>
{t('picoclaw.connection.transport.reconnect')}
</Button>
</div>
</div>
) : (
<>
<MessageList messages={messages} runState={runState} />
<div className="border-t border-white/[0.06] px-3 pb-3 pt-3">
<MessageInput
transportState={transportState}
disabled={actionBusy}
onSend={handleSend}
onNewConversation={handleNewConversation}
disableNewConversation={isFreshConversation || actionBusy}
/>
</div>
</>
)}
</>
)}
</div>

View File

@@ -7,6 +7,8 @@ import {
getPicoclawRuntimeInstallSnapshot,
type PicoclawRuntimeInstallSnapshot
} from '@/lib/picoclaw-storage.ts';
import { aiControlStatusAtom } from '@/jotai/ai-control.ts';
import { keyboardLockAtom } from '@/jotai/keyboard.ts';
import {
picoclawConfigAtom,
picoclawMessagesAtom,
@@ -23,12 +25,17 @@ import {
getPicoclawSidebarStatusColor,
isPicoclawRuntimeInstalling
} from './runtime-view.ts';
import { createPicoclawSidebarActions } from './sidebar-actions.ts';
import {
createPicoclawSidebarActions,
type PicoclawMutation
} from './sidebar-actions.ts';
import {
usePicoclawGatewayAutoConnect,
usePicoclawGatewayEvents,
usePicoclawInstallRefresh,
usePicoclawInstallSnapshotSync,
usePicoclawSidebarLifecycle
usePicoclawSidebarLifecycle,
usePicoclawStatusRefresh
} from './sidebar-effects.ts';
import { createPicoclawSidebarSessionActions } from './sidebar-session-actions.ts';
@@ -39,9 +46,11 @@ export const useSidebar = () => {
const [transportState, setTransportState] = useAtom(picoclawTransportStateAtom);
const [runState, setRunState] = useAtom(picoclawRunStateAtom);
const [runtimeStatus, setRuntimeStatus] = useAtom(picoclawRuntimeStatusAtom);
const [aiControlStatus, setAIControlStatus] = useAtom(aiControlStatusAtom);
const [config] = useAtom(picoclawConfigAtom);
const [, setTakeover] = useAtom(picoclawTakeoverStateAtom);
const setOverlay = useSetAtom(picoclawOverlayAtom);
const setKeyboardLock = useSetAtom(keyboardLockAtom);
const previousInstallStateRef = useRef<{ installing: boolean; status: string }>({
installing: false,
status: ''
@@ -59,6 +68,12 @@ export const useSidebar = () => {
const [isSwitchingAgent, setIsSwitchingAgent] = useState(false);
const [isSwitchingSession, setIsSwitchingSession] = useState(false);
const [isTogglingRuntime, setIsTogglingRuntime] = useState(false);
const isTogglingRuntimeRef = useRef(false);
const [isReleasingControl, setIsReleasingControl] = useState(false);
const [picoclawMutation, setPicoclawMutation] = useState<PicoclawMutation>('none');
const mutationRef = useRef<PicoclawMutation>('none');
const mutationEpochRef = useRef(0);
const refreshRequestSeqRef = useRef(0);
const [isFreshConversation, setIsFreshConversation] = useState(true);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [isDeletingSession, setIsDeletingSession] = useState(false);
@@ -67,17 +82,28 @@ export const useSidebar = () => {
const [installSnapshot, setInstallSnapshot] = useState<PicoclawRuntimeInstallSnapshot | null>(
() => getPicoclawRuntimeInstallSnapshot()
);
const refreshStatePromiseRef = useRef<Promise<NonNullable<typeof runtimeStatus> | null> | null>(
null
);
const isRuntimeMutationPending =
picoclawMutation === 'runtime_start' ||
picoclawMutation === 'runtime_stop' ||
picoclawMutation === 'runtime_install' ||
picoclawMutation === 'runtime_uninstall';
const isControlMutationPending =
picoclawMutation === 'control_grant' || picoclawMutation === 'control_release';
const isSidebarMutationPending = picoclawMutation !== 'none';
const isRuntimeStatusInstalling = isPicoclawRuntimeInstalling(runtimeStatus);
const isSnapshotInstalling = !runtimeStatus && installSnapshot?.installing === true;
const isRuntimeInstallActive = isRuntimeStatusInstalling || isSnapshotInstalling;
const isInstallingRuntime = isInstallRequestPending || isRuntimeInstallActive;
const installProgress = isRuntimeStatusInstalling
? runtimeStatus?.install_progress ?? installSnapshot?.installProgress
? (runtimeStatus?.install_progress ?? installSnapshot?.installProgress)
: isSnapshotInstalling
? installSnapshot?.installProgress
: undefined;
const installStage = isRuntimeStatusInstalling
? runtimeStatus?.install_stage ?? installSnapshot?.installStage
? (runtimeStatus?.install_stage ?? installSnapshot?.installStage)
: isSnapshotInstalling
? installSnapshot?.installStage
: undefined;
@@ -89,7 +115,12 @@ export const useSidebar = () => {
modelApiBase,
modelApiKey,
modelIdentifier,
refreshStatePromiseRef,
mutationRef,
mutationEpochRef,
refreshRequestSeqRef,
setRuntimeStatus,
setAIControlStatus,
setMessages,
setTakeover,
setOverlay,
@@ -101,10 +132,14 @@ export const useSidebar = () => {
setInstallSnapshot,
setIsSavingModelConfig,
setIsSwitchingAgent,
setIsUninstallRequestPending
setIsUninstallRequestPending,
setIsReleasingControl,
setPicoclawMutation,
setKeyboardLock
});
const refreshStateRef = useRef(actions.refreshState);
refreshStateRef.current = actions.refreshState;
isTogglingRuntimeRef.current = isTogglingRuntime;
const sessionActions = createPicoclawSidebarSessionActions({
t,
transportState,
@@ -112,6 +147,7 @@ export const useSidebar = () => {
config,
activeSessionId,
isFreshConversation,
isSidebarMutationPending,
isSwitchingSession,
refreshState: refreshStateRef.current,
setMessages,
@@ -132,12 +168,17 @@ export const useSidebar = () => {
usePicoclawGatewayEvents({
t,
refreshStateRef,
setActiveSessionId,
setTakeover,
setMessages,
setTransportState,
setOverlay,
setRunState
setRunState,
setRuntimeStatus,
setAIControlStatus,
setKeyboardLock,
isTogglingRuntimeRef
});
usePicoclawSidebarLifecycle({
t,
@@ -152,6 +193,13 @@ export const useSidebar = () => {
setRunState
});
usePicoclawInstallRefresh(isRuntimeInstallActive, refreshStateRef);
usePicoclawGatewayAutoConnect({
activeSessionId,
runtimeStatus,
transportState,
disabled: isSidebarMutationPending
});
usePicoclawStatusRefresh(isRuntimeInstallActive || isSidebarMutationPending, refreshStateRef);
usePicoclawInstallSnapshotSync({
t,
runtimeStatus,
@@ -181,6 +229,7 @@ export const useSidebar = () => {
return {
activeSessionId,
aiControlStatus,
connectionLabel,
handleCancelModelConfig: () => setIsModelConfigOpen(false),
handleAgentProfileChange: actions.handleAgentProfileChange,
@@ -190,6 +239,7 @@ export const useSidebar = () => {
handleModelApiKeyChange: setModelApiKey,
handleModelIdentifierChange: setModelIdentifier,
handleNewConversation: sessionActions.handleNewConversation,
handleReconnectGateway: sessionActions.handleReconnectGateway,
handleOpenHistory: sessionActions.handleOpenHistory,
handleOpenModelConfig: () => {
setIsHistoryOpen(false);
@@ -210,7 +260,11 @@ export const useSidebar = () => {
installStage,
isSavingModelConfig,
isSwitchingAgent,
isControlMutationPending,
isRuntimeMutationPending,
isSidebarMutationPending,
isTogglingRuntime,
isReleasingControl,
messages,
modelApiBase,
modelApiKey,
@@ -218,13 +272,17 @@ export const useSidebar = () => {
isModelConfigOpen,
runState,
runtimeStatus,
picoclawMutation,
sidebarMode,
statusColor,
handleGrantControl: actions.handleGrantControl,
handleInstallRuntime: actions.handleInstallRuntime,
handleReleaseControl: actions.handleReleaseControl,
handleUninstallRuntime: actions.handleUninstallRuntime,
transportState,
handleSend: sessionActions.handleSend,
handleStartRuntime: actions.handleStartRuntime,
refreshRuntimeState: actions.refreshState,
handleStop: sessionActions.handleStop
};
};