mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
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:
20
web/src/api/mcp.ts
Normal file
20
web/src/api/mcp.ts
Normal 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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
74
web/src/jotai/ai-control.ts
Normal file
74
web/src/jotai/ai-control.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
320
web/src/pages/desktop/menu/settings/mcp/index.tsx
Normal file
320
web/src/pages/desktop/menu/settings/mcp/index.tsx
Normal 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>
|
||||
);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
17
web/src/pages/desktop/picoclaw/keyboard-lock.ts
Normal file
17
web/src/pages/desktop/picoclaw/keyboard-lock.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user