refactor(desktop): make Tauri thin shell running web sidecar (#273)
## What / Why This PR finishes the desktop refactor: the Tauri app is now a thin shell that launches the web server as a sidecar and loads the UI from `http://127.0.0.1:<port>`. All real backend logic lives in `packages/web/server/index.js`; desktop Rust keeps only native integrations (menu/dialog/notifications/updater/deep-link + window chrome). This unblocks: - consistent behavior across web/desktop/vscode (single backend) - simpler desktop maintenance (no duplicated Rust backend) - host switching between Local + remote instances in desktop - reliable cold-start behavior on slow machines (VSCode + desktop) ## Key changes - Desktop sidecar runtime - build pipeline to bundle web dist + `openchamber-server` sidecar (`packages/desktop/scripts/build-sidecar.mjs`) - robust local port selection (prefer saved/default, fallback to random; persisted in `~/.config/openchamber/settings.json`) - improved PATH handling so the sidecar can locate `opencode` CLI (incl `~/.opencode/bin`, overrides, common bins) - disable native right-click context menu in production builds (dev keeps it) - Desktop instance switcher (Tauri-only) - header button + modal to add/edit/delete remote hosts, set default, probe status/ping, switch back to Local escape hatch - auth gate includes host switcher so you can recover when a remote host is broken/auth-required - host list stored desktop-locally (not tied to the currently selected remote server) - Notifications - decision logic moved server-side; desktop notifications emitted via sidecar stdout and shown natively by Tauri - prevent double-notifications on desktop Local origin (UI ignores SSE notification when native path is active) - restore macOS notification sound - Updates - Tauri updater used only when viewing Local instance in desktop shell (avoid “remote web update” triggering desktop restart) - Settings persistence & UX polish - persist model favorites/recents via `/api/config/settings` (works for web + desktop; not origin-dependent) - persist per-project sidebar collapse state in `projects[].sidebarCollapsed` via `/api/config/settings` (with debounce on toggles) - macOS header sizing/traffic-lights offsets fixed (marketing macOS major injected from desktop; MultiRun header aligned) - VSCode cold-start: keep retrying provider/agent loads after connection to avoid empty UI on slow machines - misc lint/type fixes + bun.lock sync - Desktop bootstrap / resiliency - show onboarding screen when OpenCode CLI is missing (desktop Local origin), with retry hook to restart OpenCode after install ## Testing notes - Desktop (macOS): switch Local <-> remote, set default host, verify auth gate recovery, native notifications (with sound), updater gated to Local - Web: favorites/recents + per-project collapsed state persist across reload/restart - VSCode: slow startup no longer results in missing providers/agents/models
This commit is contained in:
committed by
GitHub
parent
b733f26aed
commit
83ffb1af34
@@ -121,7 +121,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
||||
}))
|
||||
);
|
||||
|
||||
const { phase: activityPhase, isWorking: isPhaseWorking, isCooldown: isPhaseCooldown } = useCurrentSessionActivity();
|
||||
const { phase: activityPhase, isWorking: isPhaseWorking } = useCurrentSessionActivity();
|
||||
|
||||
const sessionMessages = React.useMemo<Array<{ info: Message; parts: Part[] }>>(() => {
|
||||
if (!currentSessionId) {
|
||||
@@ -298,7 +298,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
||||
|
||||
const isWorking = isPhaseWorking;
|
||||
const isStreaming = activityPhase === 'busy';
|
||||
const isCooldown = isPhaseCooldown;
|
||||
const isCooldown = false;
|
||||
|
||||
let activity: AssistantActivity = 'idle';
|
||||
if (isWorking) {
|
||||
@@ -327,9 +327,9 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
||||
wasAborted: false,
|
||||
abortActive: false,
|
||||
lastCompletionId: null,
|
||||
isComplete: isCooldown,
|
||||
isComplete: false,
|
||||
};
|
||||
}, [activityPhase, isPhaseWorking, isPhaseCooldown, parsedStatus, abortState]);
|
||||
}, [activityPhase, isPhaseWorking, parsedStatus, abortState]);
|
||||
|
||||
const forming = React.useMemo<FormingSummary>(() => {
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
fetchDesktopServerInfo,
|
||||
isDesktopRuntime,
|
||||
type DesktopServerInfo
|
||||
} from "@/lib/desktop";
|
||||
|
||||
export const useDesktopServerInfo = (pollInterval = 5000): DesktopServerInfo | null => {
|
||||
const [info, setInfo] = useState<DesktopServerInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopRuntime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
const payload = await fetchDesktopServerInfo();
|
||||
if (!cancelled) {
|
||||
setInfo(payload);
|
||||
timer = setTimeout(poll, pollInterval);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [pollInterval]);
|
||||
|
||||
return info;
|
||||
};
|
||||
@@ -15,7 +15,7 @@ import { handleTodoUpdatedEvent } from '@/stores/useTodoStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { isWebRuntime } from '@/lib/desktop';
|
||||
import { isDesktopLocalOriginActive } from '@/lib/desktop';
|
||||
|
||||
interface EventData {
|
||||
type: string;
|
||||
@@ -99,36 +99,6 @@ const getMessageFromStore = (sessionId: string, messageId: string): { info: Mess
|
||||
return message;
|
||||
};
|
||||
|
||||
const formatModelID = (raw: string): string => {
|
||||
if (!raw) {
|
||||
return 'Assistant';
|
||||
}
|
||||
|
||||
const tokens: string[] = raw.split(/[-_]/);
|
||||
const result: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < tokens.length) {
|
||||
const current = tokens[i];
|
||||
|
||||
if (/^\d+$/.test(current)) {
|
||||
if (i + 1 < tokens.length && /^\d+$/.test(tokens[i + 1])) {
|
||||
const combined = `${current}.${tokens[i + 1]}`;
|
||||
result.push(combined);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(current);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return result
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
export const useEventStream = () => {
|
||||
const {
|
||||
addStreamingPart,
|
||||
@@ -149,8 +119,6 @@ export const useEventStream = () => {
|
||||
|
||||
const { checkConnection } = useConfigStore();
|
||||
const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled);
|
||||
const notificationMode = useUIStore((state) => state.notificationMode);
|
||||
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
|
||||
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
|
||||
const activeSessionDirectory = React.useMemo(() => {
|
||||
@@ -412,7 +380,6 @@ export const useEventStream = () => {
|
||||
(sessionId: string, reason: string, limit?: number) => Promise<void>
|
||||
>(() => Promise.resolve());
|
||||
const scheduleReconnectRef = React.useRef<(hint?: string) => void>(() => {});
|
||||
const isDesktopRuntimeRef = React.useRef<boolean>(false);
|
||||
|
||||
const maybeBootstrapIfStale = React.useCallback(
|
||||
(reason: string) => {
|
||||
@@ -425,17 +392,6 @@ export const useEventStream = () => {
|
||||
[bootstrapState]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isDesktop?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
if (apis?.runtime?.isDesktop) {
|
||||
isDesktopRuntimeRef.current = true;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sessionCooldownTimersRef = React.useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const sessionActivityPhaseRef = React.useRef<Map<string, 'idle' | 'busy' | 'cooldown'>>(new Map());
|
||||
const sessionStatusLastRefreshAtRef = React.useRef<number>(0);
|
||||
const sessionStatusRefreshInFlightRef = React.useRef<Promise<void> | null>(null);
|
||||
const currentSessionIdRef = React.useRef<string | null>(currentSessionId);
|
||||
@@ -509,15 +465,46 @@ export const useEventStream = () => {
|
||||
);
|
||||
|
||||
|
||||
const updateSessionActivityPhase = React.useCallback((sessionId: string, phase: 'idle' | 'busy' | 'cooldown') => {
|
||||
const storePhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
||||
if (storePhase === phase) {
|
||||
sessionActivityPhaseRef.current = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map());
|
||||
return;
|
||||
type SessionStatusPayload = {
|
||||
type: 'idle' | 'busy' | 'retry';
|
||||
attempt?: number;
|
||||
message?: string;
|
||||
next?: number;
|
||||
};
|
||||
|
||||
const updateSessionStatus = React.useCallback((
|
||||
sessionId: string,
|
||||
status: SessionStatusPayload,
|
||||
source: string = 'unknown'
|
||||
) => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const storeStatus = useSessionStore.getState().sessionStatus?.get(sessionId);
|
||||
const prevType = storeStatus?.type ?? 'idle';
|
||||
const nextType = status?.type ?? 'idle';
|
||||
|
||||
if (prevType !== nextType) {
|
||||
try {
|
||||
console.info('[SESSION-STATUS]', {
|
||||
sessionId,
|
||||
from: prevType,
|
||||
to: nextType,
|
||||
source,
|
||||
...(nextType === 'retry'
|
||||
? {
|
||||
attempt: status.attempt,
|
||||
next: status.next,
|
||||
message: status.message,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const shouldArmMessageStallCheck = storePhase === 'idle' && (phase === 'busy' || phase === 'cooldown');
|
||||
const shouldDisarmMessageStallCheck = phase === 'idle';
|
||||
const shouldArmMessageStallCheck = prevType === 'idle' && (nextType === 'busy' || nextType === 'retry');
|
||||
const shouldDisarmMessageStallCheck = nextType === 'idle';
|
||||
|
||||
if (shouldDisarmMessageStallCheck) {
|
||||
const pending = pendingMessageStallTimersRef.current.get(sessionId);
|
||||
@@ -536,8 +523,8 @@ export const useEventStream = () => {
|
||||
|
||||
const startAt = Date.now();
|
||||
const timer = setTimeout(() => {
|
||||
const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
||||
if (currentPhase !== 'busy' && currentPhase !== 'cooldown') {
|
||||
const current = useSessionStore.getState().sessionStatus?.get(sessionId);
|
||||
if (current?.type !== 'busy' && current?.type !== 'retry') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -552,43 +539,25 @@ export const useEventStream = () => {
|
||||
}
|
||||
|
||||
lastMessageStallRecoveryBySessionRef.current.set(sessionId, Date.now());
|
||||
|
||||
void scheduleSoftResyncRef.current(sessionId, 'activity_started_no_message', getActiveSessionWindow())
|
||||
void scheduleSoftResyncRef.current(sessionId, 'status_busy_no_message', getActiveSessionWindow())
|
||||
.finally(() => {
|
||||
scheduleReconnectRef.current('No message events after activity start');
|
||||
scheduleReconnectRef.current('No message events after busy status');
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
pendingMessageStallTimersRef.current.set(sessionId, timer);
|
||||
}
|
||||
|
||||
const existingTimer = sessionCooldownTimersRef.current.get(sessionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
sessionCooldownTimersRef.current.delete(sessionId);
|
||||
}
|
||||
|
||||
const next = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map());
|
||||
next.set(sessionId, phase);
|
||||
sessionActivityPhaseRef.current = next;
|
||||
useSessionStore.setState({ sessionActivityPhase: next });
|
||||
|
||||
if (phase === 'cooldown') {
|
||||
const timer = setTimeout(() => {
|
||||
sessionCooldownTimersRef.current.delete(sessionId);
|
||||
const current = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
||||
if (current === 'cooldown') {
|
||||
const latest = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map());
|
||||
latest.set(sessionId, 'idle');
|
||||
sessionActivityPhaseRef.current = latest;
|
||||
useSessionStore.setState({ sessionActivityPhase: latest });
|
||||
}
|
||||
}, 2000);
|
||||
sessionCooldownTimersRef.current.set(sessionId, timer);
|
||||
const next = new Map(useSessionStore.getState().sessionStatus ?? new Map());
|
||||
if (nextType === 'idle') {
|
||||
next.delete(sessionId);
|
||||
} else {
|
||||
next.set(sessionId, status);
|
||||
}
|
||||
useSessionStore.setState({ sessionStatus: next });
|
||||
}, []);
|
||||
|
||||
const refreshSessionActivityStatus = React.useCallback(async () => {
|
||||
const refreshSessionStatus = React.useCallback(async () => {
|
||||
const now = Date.now();
|
||||
if (sessionStatusRefreshInFlightRef.current) {
|
||||
return sessionStatusRefreshInFlightRef.current;
|
||||
@@ -598,8 +567,8 @@ export const useEventStream = () => {
|
||||
}
|
||||
sessionStatusLastRefreshAtRef.current = now;
|
||||
|
||||
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
|
||||
const observed = new Set<string>();
|
||||
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
|
||||
const observed = new Set<string>();
|
||||
// Use getState() to avoid sessions dependency which causes cascading updates
|
||||
const currentSessions = useSessionStore.getState().sessions;
|
||||
const knownSessionIds = new Set(currentSessions.map((session) => session.id));
|
||||
@@ -607,38 +576,37 @@ export const useEventStream = () => {
|
||||
for (const [sessionId, raw] of Object.entries(statusMap)) {
|
||||
if (!sessionId || !raw) continue;
|
||||
observed.add(sessionId);
|
||||
const phase: 'idle' | 'busy' =
|
||||
raw.type === 'busy' || raw.type === 'retry' ? 'busy' : 'idle';
|
||||
updateSessionActivityPhase(sessionId, phase);
|
||||
const typeRaw = raw.type;
|
||||
const status: SessionStatusPayload =
|
||||
typeRaw === 'retry'
|
||||
? {
|
||||
type: 'retry',
|
||||
attempt: (raw as { attempt?: unknown }).attempt as number | undefined,
|
||||
message: (raw as { message?: unknown }).message as string | undefined,
|
||||
next: (raw as { next?: unknown }).next as number | undefined,
|
||||
}
|
||||
: typeRaw === 'busy' || typeRaw === 'cooldown'
|
||||
? { type: 'busy' }
|
||||
: { type: 'idle' };
|
||||
updateSessionStatus(sessionId, status, 'poll:/session/status');
|
||||
}
|
||||
|
||||
// OpenCode's /session/status may omit idle sessions (returns only busy/retry).
|
||||
// Treat missing entries as idle to avoid sessions getting stuck "working".
|
||||
const currentPhases = useSessionStore.getState().sessionActivityPhase;
|
||||
if (!currentPhases) return;
|
||||
const currentStatuses = useSessionStore.getState().sessionStatus;
|
||||
if (!currentStatuses) return;
|
||||
|
||||
for (const [sessionId, phase] of currentPhases.entries()) {
|
||||
for (const [sessionId, status] of currentStatuses.entries()) {
|
||||
if (!knownSessionIds.has(sessionId)) continue;
|
||||
if ((phase === 'busy' || phase === 'cooldown') && !observed.has(sessionId)) {
|
||||
updateSessionActivityPhase(sessionId, 'idle');
|
||||
if ((status.type === 'busy' || status.type === 'retry') && !observed.has(sessionId)) {
|
||||
updateSessionStatus(sessionId, { type: 'idle' }, 'poll:missing->idle');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const task = (async (): Promise<void> => {
|
||||
try {
|
||||
// Try web server's tracked activity first - more reliable on visibility restore
|
||||
// because it tracks activity even when UI is not listening to SSE.
|
||||
// Only available in web runtime (desktop/vscode use native events instead).
|
||||
if (isWebRuntime()) {
|
||||
const webServerActivity = await opencodeClient.getWebServerSessionActivity();
|
||||
if (webServerActivity && Object.keys(webServerActivity).length > 0) {
|
||||
applyStatusMap(webServerActivity);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to OpenCode's global session status
|
||||
// OpenCode global session status (busy/retry only; idle omitted)
|
||||
const globalStatusMap = await opencodeClient.getGlobalSessionStatus();
|
||||
if (globalStatusMap && Object.keys(globalStatusMap).length > 0) {
|
||||
applyStatusMap(globalStatusMap);
|
||||
@@ -677,11 +645,11 @@ export const useEventStream = () => {
|
||||
}
|
||||
|
||||
if (Object.keys(merged).length === 0) {
|
||||
const hasActivePhases = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
|
||||
(phase) => phase === 'busy' || phase === 'cooldown'
|
||||
const hasActiveStatuses = Array.from(useSessionStore.getState().sessionStatus?.values?.() ?? []).some(
|
||||
(status) => status?.type === 'busy' || status?.type === 'retry'
|
||||
);
|
||||
|
||||
if (hasActivePhases) {
|
||||
if (hasActiveStatuses) {
|
||||
const healthy = await opencodeClient.checkHealth().catch(() => false);
|
||||
if (!healthy) {
|
||||
return;
|
||||
@@ -699,7 +667,7 @@ export const useEventStream = () => {
|
||||
|
||||
sessionStatusRefreshInFlightRef.current = task;
|
||||
return task;
|
||||
}, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, updateSessionActivityPhase]);
|
||||
}, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, updateSessionStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const nextSessionId = currentSessionId ?? null;
|
||||
@@ -709,13 +677,13 @@ export const useEventStream = () => {
|
||||
|
||||
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
||||
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
||||
void refreshSessionActivityStatus();
|
||||
void refreshSessionStatus();
|
||||
}
|
||||
}
|
||||
|
||||
previousSessionIdRef.current = nextSessionId;
|
||||
previousSessionDirectoryRef.current = nextDirectory;
|
||||
}, [currentSessionId, refreshSessionActivityStatus, resolveSessionDirectoryForStatus]);
|
||||
}, [currentSessionId, refreshSessionStatus, resolveSessionDirectoryForStatus]);
|
||||
|
||||
const handleEvent = React.useCallback((event: EventData) => {
|
||||
lastEventTimestampRef.current = Date.now();
|
||||
@@ -766,15 +734,6 @@ export const useEventStream = () => {
|
||||
void bootstrapState('server_disposed_event');
|
||||
break;
|
||||
}
|
||||
case 'openchamber:session-activity': {
|
||||
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : null;
|
||||
const phase = typeof props.phase === 'string' ? props.phase : null;
|
||||
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
||||
updateSessionActivityPhase(sessionId, phase);
|
||||
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'mcp.tools.changed': {
|
||||
const directory = typeof props.directory === 'string' ? props.directory : effectiveDirectory;
|
||||
@@ -783,17 +742,25 @@ export const useEventStream = () => {
|
||||
}
|
||||
|
||||
case 'session.status':
|
||||
if (isDesktopRuntimeRef.current) break;
|
||||
{
|
||||
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
|
||||
const statusObj = (typeof props.status === 'object' && props.status !== null) ? props.status as Record<string, unknown> : null;
|
||||
const statusType = typeof statusObj?.type === 'string' ? statusObj.type : null;
|
||||
const statusInfo = statusObj ?? {};
|
||||
|
||||
if (sessionId && statusType) {
|
||||
updateSessionActivityPhase(
|
||||
sessionId,
|
||||
statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle',
|
||||
);
|
||||
if (statusType === 'busy') {
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status');
|
||||
} else if (statusType === 'retry') {
|
||||
updateSessionStatus(sessionId, {
|
||||
type: 'retry',
|
||||
attempt: typeof statusInfo.attempt === 'number' ? statusInfo.attempt : undefined,
|
||||
message: typeof statusInfo.message === 'string' ? statusInfo.message : undefined,
|
||||
next: typeof statusInfo.next === 'number' ? statusInfo.next : undefined,
|
||||
}, 'sse:session.status');
|
||||
} else {
|
||||
updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.status');
|
||||
}
|
||||
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
|
||||
}
|
||||
}
|
||||
@@ -877,6 +844,25 @@ export const useEventStream = () => {
|
||||
type: part.type || 'text',
|
||||
} as Part;
|
||||
|
||||
// Fallback: if we see assistant parts but session.status hasn't arrived yet, mark busy.
|
||||
if (roleInfo === 'assistant') {
|
||||
const partType = (messagePart as { type?: unknown }).type;
|
||||
const isStreamingPart =
|
||||
partType === 'step-start' ||
|
||||
partType === 'text' ||
|
||||
partType === 'tool' ||
|
||||
partType === 'reasoning' ||
|
||||
partType === 'file' ||
|
||||
partType === 'patch';
|
||||
|
||||
if (isStreamingPart) {
|
||||
const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId);
|
||||
if (!currentStatus || currentStatus.type === 'idle') {
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.updated');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trackMessage(messageId, 'addStreamingPart_called');
|
||||
addStreamingPart(sessionId, messageId, messagePart, roleInfo);
|
||||
break;
|
||||
@@ -926,7 +912,7 @@ export const useEventStream = () => {
|
||||
break;
|
||||
}
|
||||
|
||||
if (isDesktopRuntimeRef.current && streamDebugEnabled()) {
|
||||
if (streamDebugEnabled()) {
|
||||
try {
|
||||
const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts || [];
|
||||
const textParts = Array.isArray(serverParts)
|
||||
@@ -1137,21 +1123,6 @@ export const useEventStream = () => {
|
||||
trackMessage(messageId, 'skipped_shrinking_update', { incomingLen, existingLen });
|
||||
break;
|
||||
}
|
||||
|
||||
if (isDesktopRuntimeRef.current) {
|
||||
const zeroToleranceShrink = existingLen > 0 && incomingLen < existingLen;
|
||||
const hasUsefulText = partsArray.some((p) => {
|
||||
if (!p || p.type !== 'text') return false;
|
||||
const textPart = p as { text?: string };
|
||||
return typeof textPart.text === 'string' && textPart.text.length > 0;
|
||||
});
|
||||
const shrinkAllowed = eventHasStopFinish && hasUsefulText;
|
||||
|
||||
if (zeroToleranceShrink && !shrinkAllowed) {
|
||||
trackMessage(messageId, 'desktop_shrinking_update_suppressed', { incomingLen, existingLen });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateMessageInfo(sessionId, messageId, message as unknown as Message);
|
||||
@@ -1168,9 +1139,7 @@ export const useEventStream = () => {
|
||||
{ count: partsArray.length }
|
||||
);
|
||||
|
||||
const partsToInject = isDesktopRuntimeRef.current && (messageExt as { role?: unknown }).role === 'assistant'
|
||||
? partsArray.filter((serverPart) => serverPart?.type !== 'text')
|
||||
: partsArray;
|
||||
const partsToInject = partsArray;
|
||||
|
||||
for (let i = 0; i < partsToInject.length; i++) {
|
||||
const serverPart = partsToInject[i];
|
||||
@@ -1213,11 +1182,6 @@ export const useEventStream = () => {
|
||||
const isActiveSession = currentSessionId === sessionId;
|
||||
if (isActiveSession && messageId !== latestAssistantMessageId) break;
|
||||
|
||||
if (!stopMarkerPresent && isDesktopRuntimeRef.current) {
|
||||
trackMessage(messageId, 'desktop_completion_without_stop');
|
||||
break;
|
||||
}
|
||||
|
||||
const timeCompleted =
|
||||
hasCompletedTimestamp
|
||||
? (completedCandidate as number)
|
||||
@@ -1306,53 +1270,6 @@ export const useEventStream = () => {
|
||||
|
||||
completeStreamingMessage(sessionId, messageId);
|
||||
|
||||
// Only notify when entire message is finished (finish === 'stop')
|
||||
if (finish === 'stop' && isWebRuntime() && nativeNotificationsEnabled) {
|
||||
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
|
||||
|
||||
if (shouldNotify) {
|
||||
// Check if this is a subtask and if we should notify for subtasks
|
||||
if (!notifyOnSubtasks) {
|
||||
const sessions = useSessionStore.getState().sessions;
|
||||
const session = sessions.find(s => s.id === sessionId);
|
||||
const isSubtask = session && 'parentID' in session && Boolean((session as { parentID?: string }).parentID);
|
||||
if (isSubtask) {
|
||||
// Skip notification for subtasks
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const notifiedMessages = notifiedMessagesRef.current;
|
||||
|
||||
if (!notifiedMessages.has(messageId)) {
|
||||
notifiedMessages.add(messageId);
|
||||
|
||||
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
||||
|
||||
if (runtimeAPIs?.notifications) {
|
||||
const rawMode = (messageExt as { mode?: string }).mode || 'agent';
|
||||
const rawModel = (messageExt as { modelID?: string }).modelID || 'assistant';
|
||||
|
||||
const title = `${rawMode.charAt(0).toUpperCase() + rawMode.slice(1)} agent is ready`;
|
||||
const body = `${formatModelID(rawModel)} completed the task`;
|
||||
|
||||
void runtimeAPIs.notifications.notifyAgentCompletion({ title, body, tag: messageId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For web/vscode: trigger cooldown only when assistant message has finish === "stop"
|
||||
// to match desktop backend semantics.
|
||||
if (!isDesktopRuntimeRef.current) {
|
||||
if (finish === 'stop') {
|
||||
const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
||||
if (currentPhase === 'busy') {
|
||||
updateSessionActivityPhase(sessionId, 'cooldown');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rawMessageSessionId = (message as { sessionID?: string }).sessionID;
|
||||
const messageSessionId: string =
|
||||
typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0
|
||||
@@ -1481,19 +1398,6 @@ export const useEventStream = () => {
|
||||
});
|
||||
});
|
||||
|
||||
if (isWebRuntime() && nativeNotificationsEnabled) {
|
||||
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
|
||||
if (shouldNotify) {
|
||||
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
||||
if (runtimeAPIs?.notifications) {
|
||||
void runtimeAPIs.notifications.notifyAgentCompletion({
|
||||
title: 'Permission required',
|
||||
body: sessionTitle,
|
||||
tag: `permission-${toastKey}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -1513,39 +1417,7 @@ export const useEventStream = () => {
|
||||
|
||||
const toastKey = `${request.sessionID}:${request.id}`;
|
||||
|
||||
if (isWebRuntime() && nativeNotificationsEnabled) {
|
||||
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
|
||||
|
||||
if (shouldNotify) {
|
||||
const notifiedQuestions = notifiedQuestionsRef.current;
|
||||
|
||||
if (!notifiedQuestions.has(toastKey)) {
|
||||
notifiedQuestions.add(toastKey);
|
||||
|
||||
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
||||
|
||||
if (runtimeAPIs?.notifications) {
|
||||
const first = Array.isArray(request.questions) ? request.questions[0] : undefined;
|
||||
const header = typeof first?.header === 'string' ? first.header.trim() : '';
|
||||
const questionText = typeof first?.question === 'string' ? first.question.trim() : '';
|
||||
|
||||
const title = /plan\s*mode/i.test(header)
|
||||
? 'Switch to plan mode'
|
||||
: /build\s*agent/i.test(header)
|
||||
? 'Switch to build mode'
|
||||
: header || 'Input needed';
|
||||
|
||||
const body = questionText || 'Agent is waiting for your response';
|
||||
|
||||
void runtimeAPIs.notifications.notifyAgentCompletion({
|
||||
title,
|
||||
body,
|
||||
tag: toastKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// notifications are emitted server-side (see openchamber:notification)
|
||||
|
||||
if (!questionToastShownRef.current.has(toastKey)) {
|
||||
setTimeout(() => {
|
||||
@@ -1606,6 +1478,34 @@ export const useEventStream = () => {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'openchamber:notification': {
|
||||
const title = typeof (props as { title?: unknown }).title === 'string' ? (props as { title: string }).title : '';
|
||||
const body = typeof (props as { body?: unknown }).body === 'string' ? (props as { body: string }).body : '';
|
||||
const tag = typeof (props as { tag?: unknown }).tag === 'string' ? (props as { tag: string }).tag : undefined;
|
||||
const requireHidden = Boolean((props as { requireHidden?: unknown }).requireHidden);
|
||||
|
||||
if (requireHidden && visibilityStateRef.current !== 'hidden') {
|
||||
break;
|
||||
}
|
||||
|
||||
// Desktop local instance uses native notifications via sidecar stdout.
|
||||
// Avoid duplicating via UI runtime notifications.
|
||||
if (isDesktopLocalOriginActive()) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!nativeNotificationsEnabled) {
|
||||
break;
|
||||
}
|
||||
|
||||
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
||||
if (runtimeAPIs?.notifications && title) {
|
||||
void runtimeAPIs.notifications.notifyAgentCompletion({ title, body, tag });
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'todo.updated': {
|
||||
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
|
||||
const todos = Array.isArray(props.todos) ? props.todos : null;
|
||||
@@ -1621,8 +1521,6 @@ export const useEventStream = () => {
|
||||
}, [
|
||||
currentSessionId,
|
||||
nativeNotificationsEnabled,
|
||||
notificationMode,
|
||||
notifyOnSubtasks,
|
||||
addStreamingPart,
|
||||
completeStreamingMessage,
|
||||
updateMessageInfo,
|
||||
@@ -1635,7 +1533,7 @@ export const useEventStream = () => {
|
||||
applySessionMetadata,
|
||||
trackMessage,
|
||||
reportMessage,
|
||||
updateSessionActivityPhase,
|
||||
updateSessionStatus,
|
||||
updateSession,
|
||||
removeSessionFromStore,
|
||||
bootstrapState,
|
||||
@@ -1651,7 +1549,6 @@ export const useEventStream = () => {
|
||||
const debugConnectionState = React.useCallback(() => {
|
||||
if (streamDebugEnabled()) {
|
||||
console.debug('[useEventStream] Connection state:', {
|
||||
isDesktopRuntime: isDesktopRuntimeRef.current,
|
||||
hasUnsubscribe: Boolean(unsubscribeRef.current),
|
||||
currentSessionId: currentSessionIdRef.current,
|
||||
effectiveDirectory,
|
||||
@@ -1663,8 +1560,6 @@ export const useEventStream = () => {
|
||||
}
|
||||
}, [effectiveDirectory]);
|
||||
|
||||
const waitForDesktopBridge = React.useCallback(async (): Promise<boolean> => true, []);
|
||||
|
||||
const stopStream = React.useCallback(() => {
|
||||
if (isCleaningUpRef.current) {
|
||||
if (streamDebugEnabled()) {
|
||||
@@ -1708,13 +1603,6 @@ export const useEventStream = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDesktopRuntimeRef.current) {
|
||||
const bridgeReady = await waitForDesktopBridge();
|
||||
if (!bridgeReady) {
|
||||
console.warn('[useEventStream] Desktop bridge not ready, falling back to SDK');
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.resetAttempts) {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
}
|
||||
@@ -1740,9 +1628,9 @@ export const useEventStream = () => {
|
||||
publishStatus('connected', null);
|
||||
checkConnection();
|
||||
|
||||
// Always refresh session activity status on connect to detect any
|
||||
// Always refresh session status on connect to detect any
|
||||
// already-running sessions (e.g., started via CLI before UI opened)
|
||||
void refreshSessionActivityStatus();
|
||||
void refreshSessionStatus();
|
||||
|
||||
if (shouldRefresh) {
|
||||
void bootstrapState('sse_reconnected');
|
||||
@@ -1825,8 +1713,7 @@ export const useEventStream = () => {
|
||||
requestSessionMetadataRefresh,
|
||||
handleEvent,
|
||||
effectiveDirectory,
|
||||
refreshSessionActivityStatus,
|
||||
waitForDesktopBridge,
|
||||
refreshSessionStatus,
|
||||
debugConnectionState,
|
||||
bootstrapState
|
||||
]);
|
||||
@@ -1872,24 +1759,13 @@ export const useEventStream = () => {
|
||||
}, [scheduleReconnect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const cooldownTimers = sessionCooldownTimersRef.current;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__messageTracker = trackMessage;
|
||||
}
|
||||
|
||||
let desktopActivityHandler: ((event: CustomEvent<{ sessionId?: string; phase?: string }>) => void) | null = null;
|
||||
if (isDesktopRuntimeRef.current && typeof window !== 'undefined') {
|
||||
desktopActivityHandler = (event: CustomEvent<{ sessionId?: string; phase?: string }>) => {
|
||||
const sessionId = typeof event.detail?.sessionId === 'string' ? event.detail.sessionId : null;
|
||||
const phase = typeof event.detail?.phase === 'string' ? event.detail.phase : null;
|
||||
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
||||
updateSessionActivityPhase(sessionId, phase);
|
||||
requestSessionMetadataRefresh(sessionId);
|
||||
}
|
||||
};
|
||||
window.addEventListener('openchamber:session-activity', desktopActivityHandler as EventListener);
|
||||
}
|
||||
// No-op
|
||||
|
||||
const desktopActivityHandler = null;
|
||||
|
||||
const clearPauseTimeout = () => {
|
||||
if (pauseTimeoutRef.current) {
|
||||
@@ -1924,7 +1800,7 @@ export const useEventStream = () => {
|
||||
requestSessionMetadataRefresh(sessionId);
|
||||
}
|
||||
|
||||
void refreshSessionActivityStatus();
|
||||
void refreshSessionStatus();
|
||||
publishStatus('connecting', 'Resuming stream');
|
||||
startStream({ resetAttempts: true });
|
||||
}
|
||||
@@ -1949,7 +1825,7 @@ export const useEventStream = () => {
|
||||
requestSessionMetadataRefresh(sessionId);
|
||||
scheduleSoftResync(sessionId, 'window_focus', getActiveSessionWindow());
|
||||
}
|
||||
void refreshSessionActivityStatus();
|
||||
void refreshSessionStatus();
|
||||
|
||||
publishStatus('connecting', 'Resuming stream');
|
||||
startStream({ resetAttempts: true });
|
||||
@@ -1989,7 +1865,7 @@ export const useEventStream = () => {
|
||||
void scheduleSoftResync(sessionId, 'page_show', getActiveSessionWindow());
|
||||
requestSessionMetadataRefresh(sessionId);
|
||||
}
|
||||
void refreshSessionActivityStatus();
|
||||
void refreshSessionStatus();
|
||||
startStream({ resetAttempts: true });
|
||||
}
|
||||
};
|
||||
@@ -2018,12 +1894,12 @@ export const useEventStream = () => {
|
||||
if (!shouldHoldConnection()) return;
|
||||
|
||||
const now = Date.now();
|
||||
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
|
||||
(phase) => phase === 'busy' || phase === 'cooldown'
|
||||
const hasBusySessions = Array.from(useSessionStore.getState().sessionStatus?.values?.() ?? []).some(
|
||||
(status) => status?.type === 'busy' || status?.type === 'retry'
|
||||
);
|
||||
|
||||
if (hasBusySessions) {
|
||||
void refreshSessionActivityStatus();
|
||||
void refreshSessionStatus();
|
||||
}
|
||||
if (now - lastEventTimestampRef.current > 45000) {
|
||||
Promise.resolve().then(async () => {
|
||||
@@ -2048,9 +1924,7 @@ export const useEventStream = () => {
|
||||
return () => {
|
||||
clearTimeout(startTimer);
|
||||
|
||||
if (desktopActivityHandler && typeof window !== 'undefined') {
|
||||
window.removeEventListener('openchamber:session-activity', desktopActivityHandler as EventListener);
|
||||
}
|
||||
void desktopActivityHandler;
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
@@ -2071,8 +1945,6 @@ export const useEventStream = () => {
|
||||
staleCheckIntervalRef.current = null;
|
||||
}
|
||||
|
||||
cooldownTimers.forEach((timer) => clearTimeout(timer));
|
||||
cooldownTimers.clear();
|
||||
messageCache.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time
|
||||
notifiedMessagesRef.current.clear();
|
||||
@@ -2102,13 +1974,12 @@ export const useEventStream = () => {
|
||||
scheduleReconnect,
|
||||
loadMessages,
|
||||
requestSessionMetadataRefresh,
|
||||
updateSessionActivityPhase,
|
||||
refreshSessionActivityStatus,
|
||||
updateSessionStatus,
|
||||
refreshSessionStatus,
|
||||
shouldHoldConnection,
|
||||
loadSessions,
|
||||
maybeBootstrapIfStale,
|
||||
resyncMessages,
|
||||
scheduleSoftResync,
|
||||
notifyOnSubtasks,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { isDesktopRuntime, requestDirectoryAccess, startAccessingDirectory, stopAccessingDirectory } from '@/lib/desktop';
|
||||
import { isTauriShell, requestDirectoryAccess, startAccessingDirectory, stopAccessingDirectory } from '@/lib/desktop';
|
||||
|
||||
export const useFileSystemAccess = () => {
|
||||
const [isDesktop, setIsDesktop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDesktop(isDesktopRuntime());
|
||||
setIsDesktop(isTauriShell());
|
||||
}, []);
|
||||
|
||||
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
||||
@@ -38,4 +38,4 @@ export const useFileSystemAccess = () => {
|
||||
startAccessing,
|
||||
stopAccessing
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { hasModifier } from '@/lib/utils';
|
||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
||||
|
||||
export const useKeyboardShortcuts = () => {
|
||||
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
|
||||
@@ -24,7 +24,6 @@ export const useKeyboardShortcuts = () => {
|
||||
const { working } = useAssistantStatus();
|
||||
const abortPrimedUntilRef = React.useRef<number | null>(null);
|
||||
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isDownloadingLogsRef = React.useRef(false);
|
||||
|
||||
const resetAbortPriming = React.useCallback(() => {
|
||||
if (abortPrimedTimeoutRef.current) {
|
||||
@@ -44,35 +43,8 @@ export const useKeyboardShortcuts = () => {
|
||||
}
|
||||
|
||||
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'l') {
|
||||
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
||||
const diagnostics = runtimeAPIs?.diagnostics;
|
||||
if (!diagnostics) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (isDownloadingLogsRef.current) {
|
||||
return;
|
||||
}
|
||||
isDownloadingLogsRef.current = true;
|
||||
|
||||
diagnostics
|
||||
.downloadLogs()
|
||||
.then(({ fileName, content }) => {
|
||||
const finalFileName = fileName || 'openchamber.log';
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = finalFileName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.finally(() => {
|
||||
isDownloadingLogsRef.current = false;
|
||||
});
|
||||
void showOpenCodeStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,25 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { isDesktopRuntime } from '@/lib/desktop';
|
||||
import { isTauriShell } from '@/lib/desktop';
|
||||
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
||||
|
||||
const MENU_ACTION_EVENT = 'openchamber:menu-action';
|
||||
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
|
||||
|
||||
type TauriEventApi = {
|
||||
listen?: (
|
||||
event: string,
|
||||
handler: (evt: { payload?: unknown }) => void
|
||||
) => Promise<() => void>;
|
||||
};
|
||||
|
||||
type TauriGlobal = {
|
||||
event?: TauriEventApi;
|
||||
};
|
||||
|
||||
type MenuAction =
|
||||
| 'about'
|
||||
@@ -21,6 +33,7 @@ type MenuAction =
|
||||
| 'change-workspace'
|
||||
| 'open-git-tab'
|
||||
| 'open-diff-tab'
|
||||
| 'open-files-tab'
|
||||
| 'open-terminal-tab'
|
||||
| 'theme-light'
|
||||
| 'theme-dark'
|
||||
@@ -46,10 +59,9 @@ export const useMenuActions = (
|
||||
const { addProject } = useProjectsStore();
|
||||
const { requestAccess, startAccessing } = useFileSystemAccess();
|
||||
const { setThemeMode } = useThemeSystem();
|
||||
const isDownloadingLogsRef = React.useRef(false);
|
||||
|
||||
const handleChangeWorkspace = React.useCallback(() => {
|
||||
if (isDesktopRuntime()) {
|
||||
if (isTauriShell()) {
|
||||
requestAccess('')
|
||||
.then(async (result) => {
|
||||
if (!result.success || !result.path) {
|
||||
@@ -80,15 +92,13 @@ export const useMenuActions = (
|
||||
console.error('Desktop: Error selecting directory:', error);
|
||||
toast.error('Failed to select directory');
|
||||
});
|
||||
} else {
|
||||
sessionEvents.requestDirectoryDialog();
|
||||
}
|
||||
|
||||
sessionEvents.requestDirectoryDialog();
|
||||
}, [addProject, requestAccess, startAccessing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleMenuAction = (event: Event) => {
|
||||
const action = (event as CustomEvent<MenuAction>).detail;
|
||||
|
||||
const handleAction = React.useCallback(
|
||||
(action: MenuAction) => {
|
||||
switch (action) {
|
||||
case 'about':
|
||||
setAboutDialogOpen(true);
|
||||
@@ -130,6 +140,12 @@ export const useMenuActions = (
|
||||
break;
|
||||
}
|
||||
|
||||
case 'open-files-tab': {
|
||||
const { activeMainTab } = useUIStore.getState();
|
||||
setActiveMainTab(activeMainTab === 'files' ? 'chat' : 'files');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'open-terminal-tab': {
|
||||
const { activeMainTab } = useUIStore.getState();
|
||||
setActiveMainTab(activeMainTab === 'terminal' ? 'chat' : 'terminal');
|
||||
@@ -161,54 +177,86 @@ export const useMenuActions = (
|
||||
break;
|
||||
|
||||
case 'download-logs': {
|
||||
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
||||
const diagnostics = runtimeAPIs?.diagnostics;
|
||||
if (!diagnostics || isDownloadingLogsRef.current) {
|
||||
break;
|
||||
}
|
||||
|
||||
isDownloadingLogsRef.current = true;
|
||||
diagnostics
|
||||
.downloadLogs()
|
||||
.then(({ fileName, content }) => {
|
||||
const finalFileName = fileName || 'openchamber.log';
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = finalFileName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Logs saved', {
|
||||
description: `Downloaded to ~/Downloads/${finalFileName}`,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to download logs');
|
||||
})
|
||||
.finally(() => {
|
||||
isDownloadingLogsRef.current = false;
|
||||
});
|
||||
void showOpenCodeStatus().catch(() => {
|
||||
toast.error('Failed to collect OpenCode status');
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
handleChangeWorkspace,
|
||||
onToggleMemoryDebug,
|
||||
openNewSessionDraft,
|
||||
setAboutDialogOpen,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
setSettingsDialogOpen,
|
||||
setThemeMode,
|
||||
toggleCommandPalette,
|
||||
toggleHelpDialog,
|
||||
toggleSidebar,
|
||||
]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleMenuAction = (event: Event) => {
|
||||
const action = (event as CustomEvent<MenuAction>).detail;
|
||||
if (!action) return;
|
||||
handleAction(action);
|
||||
};
|
||||
|
||||
window.addEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
||||
return () => window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
||||
}, [
|
||||
openNewSessionDraft,
|
||||
toggleCommandPalette,
|
||||
toggleHelpDialog,
|
||||
toggleSidebar,
|
||||
setSessionSwitcherOpen,
|
||||
setActiveMainTab,
|
||||
setSettingsDialogOpen,
|
||||
setAboutDialogOpen,
|
||||
setThemeMode,
|
||||
onToggleMemoryDebug,
|
||||
handleChangeWorkspace,
|
||||
]);
|
||||
}, [handleAction]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
const listen = tauri?.event?.listen;
|
||||
if (typeof listen !== 'function') return;
|
||||
|
||||
let unlistenMenu: null | (() => void | Promise<void>) = null;
|
||||
let unlistenUpdate: null | (() => void | Promise<void>) = null;
|
||||
|
||||
listen('openchamber:menu-action', (evt) => {
|
||||
const action = evt?.payload;
|
||||
if (typeof action !== 'string') return;
|
||||
handleAction(action as MenuAction);
|
||||
})
|
||||
.then((fn) => {
|
||||
unlistenMenu = fn;
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore
|
||||
});
|
||||
|
||||
listen('openchamber:check-for-updates', () => {
|
||||
window.dispatchEvent(new Event(CHECK_FOR_UPDATES_EVENT));
|
||||
})
|
||||
.then((fn) => {
|
||||
unlistenUpdate = fn;
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore
|
||||
});
|
||||
|
||||
return () => {
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
const a = unlistenMenu?.();
|
||||
if (a instanceof Promise) await a;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const b = unlistenUpdate?.();
|
||||
if (b instanceof Promise) await b;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void cleanup();
|
||||
};
|
||||
}, [handleAction]);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,4 @@ export const useRuntimeAPI = <TValue,>(selector: RuntimeAPISelector<TValue>): TV
|
||||
return selector(apis);
|
||||
};
|
||||
|
||||
export const useIsDesktopRuntime = (): boolean => useRuntimeAPI((api) => api.runtime.isDesktop);
|
||||
|
||||
export const useIsVSCodeRuntime = (): boolean => useRuntimeAPI((api) => api.runtime.isVSCode);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import React from 'react';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
|
||||
export type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
|
||||
// Mirrors OpenCode SessionStatus: busy|retry|idle.
|
||||
export type SessionActivityPhase = 'idle' | 'busy' | 'retry';
|
||||
|
||||
export interface SessionActivityResult {
|
||||
|
||||
@@ -13,6 +14,7 @@ export interface SessionActivityResult {
|
||||
|
||||
isBusy: boolean;
|
||||
|
||||
// Kept for backward compatibility; always false with server session.status.
|
||||
isCooldown: boolean;
|
||||
}
|
||||
|
||||
@@ -26,10 +28,11 @@ const IDLE_RESULT: SessionActivityResult = {
|
||||
export function useSessionActivity(sessionId: string | null | undefined): SessionActivityResult {
|
||||
|
||||
const phase = useSessionStore((state) => {
|
||||
if (!sessionId || !state.sessionActivityPhase) {
|
||||
if (!sessionId || !state.sessionStatus) {
|
||||
return 'idle' as SessionActivityPhase;
|
||||
}
|
||||
return state.sessionActivityPhase.get(sessionId) ?? ('idle' as SessionActivityPhase);
|
||||
const status = state.sessionStatus.get(sessionId);
|
||||
return (status?.type ?? 'idle') as SessionActivityPhase;
|
||||
});
|
||||
|
||||
return React.useMemo<SessionActivityResult>(() => {
|
||||
@@ -37,10 +40,11 @@ export function useSessionActivity(sessionId: string | null | undefined): Sessio
|
||||
return IDLE_RESULT;
|
||||
}
|
||||
const isBusy = phase === 'busy';
|
||||
const isCooldown = phase === 'cooldown';
|
||||
// No cooldown in server session.status; treat retry as working.
|
||||
const isCooldown = false;
|
||||
return {
|
||||
phase,
|
||||
isWorking: isBusy || isCooldown,
|
||||
isWorking: phase === 'busy' || phase === 'retry',
|
||||
isBusy,
|
||||
isCooldown,
|
||||
};
|
||||
|
||||
@@ -20,17 +20,15 @@ export const useSessionStatusBootstrap = () => {
|
||||
const statusMap = await opencodeClient.getGlobalSessionStatus();
|
||||
if (cancelled || !statusMap) return;
|
||||
|
||||
const phases = new Map<string, 'idle' | 'busy' | 'cooldown'>();
|
||||
const nextStatus = new Map<string, SessionStatusPayload>();
|
||||
Object.entries(statusMap).forEach(([sessionId, raw]) => {
|
||||
if (!sessionId || !raw) return;
|
||||
const status = raw as SessionStatusPayload;
|
||||
const phase: 'idle' | 'busy' | 'cooldown' =
|
||||
status.type === 'busy' || status.type === 'retry' ? 'busy' : 'idle';
|
||||
phases.set(sessionId, phase);
|
||||
nextStatus.set(sessionId, status);
|
||||
});
|
||||
|
||||
if (phases.size > 0) {
|
||||
useSessionStore.setState({ sessionActivityPhase: phases });
|
||||
if (nextStatus.size > 0) {
|
||||
useSessionStore.setState({ sessionStatus: nextStatus });
|
||||
}
|
||||
} catch { /* ignored */ }
|
||||
};
|
||||
@@ -42,4 +40,3 @@ export const useSessionStatusBootstrap = () => {
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user