Compare commits

..

7 Commits

Author SHA1 Message Date
Haileyesus
c7a0891f56 fix(shell): remove mobile auth url controls 2026-06-26 15:51:10 +03:00
Haileyesus
241ed1da54 fix(shell): support mobile terminal text selection 2026-06-26 15:38:11 +03:00
Haileyesus
ee002fc3f7 fix(shell): keep c input during auth URL flow 2026-06-26 14:25:55 +03:00
Haile
c947eaaee5 feat: play sound for pending tool requests (#918) 2026-06-25 14:57:10 +02:00
Haile
4a503b1dc8 fix(shell): prioritize user npm binaries (#913)
Interactive shells could resolve bundled or system CLIs before user-installed npm binaries.

Move existing user npm global directories to the front of PATH while preserving all other entries.

Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-24 20:15:52 +02:00
Koya Kikuchi
f6326c8082 feat(version): warn when the server was updated but not restarted (#898)
When the package is updated on disk but the long-lived server process is
not restarted, the new frontend bundle (served from disk) talks to the
old running backend. New DB-backed features then fail silently — e.g.
deleting/archiving a session appears to do nothing — because the new
schema/routes only take effect on restart.

Nothing currently detects this skew: useVersionCheck only compares the
frontend's build-time version against the latest GitHub release.

This exposes the running server's version (captured once at startup) via
/health, compares it to the frontend's build-time version in
useVersionCheck, and shows a "restart required" banner in the sidebar
(and a small indicator in the collapsed sidebar) when they differ.

- server: add `version` (RUNNING_VERSION, read once at startup) to /health
- useVersionCheck: return `restartRequired` / `runningVersion`
- SidebarFooter / SidebarCollapsed: surface a restart-required banner
- i18n: add `version.restartRequired` to all 10 sidebar locales

Verified with `tsc --noEmit` (client + server) and eslint.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-22 22:49:57 +02:00
Haile
c5fe127958 feat(skills): add provider skill management (#909)
* feat(skills): add provider skill management

Users need one settings surface to discover and install skills without manually navigating provider-specific directories.

Add provider-backed global skill installation for Claude, Codex, Gemini, and Cursor, while keeping OpenCode read-only because it reuses other providers' skill locations.

Add a responsive Skills settings tab with scoped discovery, search, refresh controls, markdown and folder uploads, upload feedback, and overflow-safe layouts.

Validate bundled skill files and paths before writing them, preserve scripts and assets, and cover provider discovery and installation behavior with tests.

* fix(skills): preserve uploaded skill folders

Folder drops discarded supporting scripts and assets.

Keep relative paths and upload every file from the selected skill folder.

Use the selected folder name for installation and cover it in provider tests.

* fix(skills): restrict standalone skill uploads

Only show Markdown files when selecting standalone skills.

Normalize browser file paths so SKILL.md is not mistaken for a folder named dot.

* fix(skills): validate installs before writing

Preserve bundled files and normalize fallback names across skill installation paths.

Validate complete batches before writing and reject existing targets to avoid partial installs.

Keep project metadata and make folder selection tolerant of casing and cancelled dialogs.

* fix(skills): overwrite existing installations

Replace an existing skill directory instead of rejecting a duplicate installation.

Remove stale supporting files so the installed directory exactly matches the new upload.
2026-06-22 22:45:27 +02:00
31 changed files with 880 additions and 268 deletions

View File

@@ -76,6 +76,19 @@ const __dirname = getModuleDir(import.meta.url);
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
// Version of the code that is actually running, captured once at process
// startup. This intentionally does NOT re-read package.json per request: after
// an update replaces the files on disk, package.json reflects the NEW version
// while this long-lived process still runs the OLD code. The frontend bundle is
// rebuilt on update, so a mismatch between this value and the frontend's
// build-time version means the server was updated but not restarted.
const RUNNING_VERSION = (() => {
try {
return JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')).version || null;
} catch {
return null;
}
})();
const MAX_FILE_UPLOAD_SIZE_MB = 200;
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
const MAX_FILE_UPLOAD_COUNT = 20;
@@ -156,7 +169,8 @@ app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
installMode
installMode,
version: RUNNING_VERSION
});
});

View File

@@ -171,6 +171,62 @@ function buildShellCommand(
return command;
}
function readEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
const resolvedKey = Object.keys(env).find((envKey) => envKey.toLowerCase() === key.toLowerCase());
return resolvedKey ? env[resolvedKey] : undefined;
}
function getPathEnvKey(env: NodeJS.ProcessEnv): string {
return Object.keys(env).find((key) => key.toLowerCase() === 'path') || 'PATH';
}
function prioritizeUserNpmGlobalBin(env: NodeJS.ProcessEnv): { key: string; value: string | undefined } {
const pathKey = getPathEnvKey(env);
const currentPath = env[pathKey];
if (!currentPath) {
return { key: pathKey, value: currentPath };
}
const delimiter = path.delimiter;
const pathEntries = currentPath.split(delimiter).filter(Boolean);
const npmPrefix = readEnvValue(env, 'npm_config_prefix');
const appData = readEnvValue(env, 'APPDATA');
const candidates = [
npmPrefix || '',
npmPrefix ? path.join(npmPrefix, 'bin') : '',
appData ? path.join(appData, 'npm') : '',
path.join(os.homedir(), 'AppData', 'Roaming', 'npm'),
path.join(os.homedir(), '.npm-global', 'bin'),
].filter(Boolean);
const normalizedPathEntries = pathEntries.map((entry) => os.platform() === 'win32' ? entry.toLowerCase() : entry);
const preferredEntries = candidates.filter((candidate, index) => {
const normalizedCandidate = os.platform() === 'win32' ? candidate.toLowerCase() : candidate;
return (
candidates.indexOf(candidate) === index &&
normalizedPathEntries.includes(normalizedCandidate)
);
});
if (preferredEntries.length === 0) {
return { key: pathKey, value: currentPath };
}
const normalizedPreferredEntries = preferredEntries.map((entry) =>
os.platform() === 'win32' ? entry.toLowerCase() : entry
);
const value = [
...preferredEntries,
...pathEntries.filter((entry) => {
const normalizedEntry = os.platform() === 'win32' ? entry.toLowerCase() : entry;
return !normalizedPreferredEntries.includes(normalizedEntry);
}),
].join(delimiter);
return { key: pathKey, value };
}
/**
* Handles websocket connections used by the standalone shell terminal UI.
*/
@@ -284,6 +340,7 @@ export function handleShellConnection(
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
const termCols = readNumber(data.cols, 80);
const termRows = readNumber(data.rows, 24);
const prioritizedPath = prioritizeUserNpmGlobalBin(process.env);
shellProcess = pty.spawn(shell, shellArgs, {
name: 'xterm-256color',
@@ -292,6 +349,7 @@ export function handleShellConnection(
cwd: resolvedProjectPath,
env: {
...process.env,
[prioritizedPath.key]: prioritizedPath.value,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
FORCE_COLOR: '3',

View File

@@ -114,7 +114,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [providerModelsLoading, setProviderModelsLoading] = useState(true);
const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false);
const lastProviderRef = useRef(provider);
const providerModelsRequestIdRef = useRef(0);
const setStoredProviderModel = useCallback((targetProvider: LLMProvider, model: string) => {
@@ -344,14 +343,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
localStorage.setItem('selected-provider', selectedSession.__provider);
}, [provider, selectedSession]);
useEffect(() => {
if (lastProviderRef.current === provider) {
return;
}
setPendingPermissionRequests([]);
lastProviderRef.current = provider;
}, [provider]);
// Permission prompts belong to a session, not to the transient provider
// selection that is synchronized after navigation.
useEffect(() => {
setPendingPermissionRequests((previous) =>
previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id),

View File

@@ -1,20 +1,29 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { ServerEvent } from '../../../contexts/WebSocketContext';
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
import { playChatCompletionSound } from '../../../utils/notificationSound';
import { playChatCompletionSound, playNotificationSound } from '../../../utils/notificationSound';
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import type { PendingPermissionRequest } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
const isActionablePermissionRequest = (request: { toolName?: unknown } | null | undefined): boolean => {
return request?.toolName !== 'ExitPlanMode' && request?.toolName !== 'exit_plan_mode';
};
const hasActionablePermissionRequests = (requests: Array<{ toolName?: unknown }> | null | undefined): boolean => {
return Array.isArray(requests) && requests.some((request) => isActionablePermissionRequest(request));
};
interface UseChatRealtimeHandlersArgs {
subscribe: (listener: (event: ServerEvent) => void) => () => void;
provider: LLMProvider;
selectedSession: ProjectSession | null;
currentSessionId: string | null;
setTokenBudget: (budget: Record<string, unknown> | null) => void;
pendingPermissionRequests: PendingPermissionRequest[];
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
streamTimerRef: MutableRefObject<number | null>;
accumulatedStreamRef: MutableRefObject<string>;
@@ -52,6 +61,7 @@ export function useChatRealtimeHandlers({
selectedSession,
currentSessionId,
setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,
@@ -62,13 +72,29 @@ export function useChatRealtimeHandlers({
onWebSocketReconnect,
sessionStore,
}: UseChatRealtimeHandlersArgs) {
// Session switches can send `chat.subscribe` before this effect has a chance
// to rebind the websocket listener. Read the visible session id from a ref
// so a fast `chat_subscribed` ack is matched against the current view, not
// the previous render's closed-over selection.
const activeViewSessionIdRef = useRef<string | null>(selectedSession?.id || currentSessionId || null);
activeViewSessionIdRef.current = selectedSession?.id || currentSessionId || null;
// Keep the latest pending-permission snapshot available to the websocket
// listener so back-to-back permission events can dedupe and re-arm the
// notification sound before React finishes a rerender.
const pendingPermissionRequestsRef = useRef(pendingPermissionRequests);
useEffect(() => {
pendingPermissionRequestsRef.current = pendingPermissionRequests;
}, [pendingPermissionRequests]);
useEffect(() => {
const handleEvent = (msg: ServerEvent) => {
if (!msg.kind) {
return;
}
const activeViewSessionId = selectedSession?.id || currentSessionId || null;
const activeViewSessionId = activeViewSessionIdRef.current;
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
// Record replay progress for every sequenced live event.
@@ -101,7 +127,16 @@ export function useChatRealtimeHandlers({
const isViewedSession = sid === activeViewSessionId;
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]);
const nextPendingPermissionRequests = msg.pendingPermissions as PendingPermissionRequest[];
const hadActionablePermissionRequests = hasActionablePermissionRequests(pendingPermissionRequestsRef.current);
const hasPendingActionablePermissionRequests = hasActionablePermissionRequests(nextPendingPermissionRequests);
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
if (hasPendingActionablePermissionRequests && !hadActionablePermissionRequests) {
void playNotificationSound();
}
}
return;
}
@@ -203,6 +238,7 @@ export function useChatRealtimeHandlers({
// hides it immediately and atomically.
onSessionIdle?.(sid);
if (sid === activeViewSessionId) {
pendingPermissionRequestsRef.current = [];
setPendingPermissionRequests([]);
}
@@ -234,10 +270,14 @@ export function useChatRealtimeHandlers({
case 'permission_request': {
if (!msg.requestId) break;
if (isActionablePermissionRequest({ toolName: msg.toolName })) {
void playNotificationSound();
}
if (sid === activeViewSessionId) {
setPendingPermissionRequests((prev) => {
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
return [...prev, {
const previousPendingPermissionRequests = pendingPermissionRequestsRef.current;
if (!previousPendingPermissionRequests.some((request) => request.requestId === msg.requestId)) {
const nextPendingPermissionRequests = [...previousPendingPermissionRequests, {
requestId: msg.requestId as string,
toolName: (msg.toolName as string) || 'UnknownTool',
input: msg.input,
@@ -245,7 +285,10 @@ export function useChatRealtimeHandlers({
sessionId: sid || null,
receivedAt: new Date(),
}];
});
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
}
}
if (sid) {
onSessionProcessing?.(sid);
@@ -255,7 +298,12 @@ export function useChatRealtimeHandlers({
case 'permission_cancelled': {
if (msg.requestId && sid === activeViewSessionId) {
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
const nextPendingPermissionRequests = pendingPermissionRequestsRef.current.filter(
(request: PendingPermissionRequest) => request.requestId !== msg.requestId,
);
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
}
break;
}
@@ -286,6 +334,7 @@ export function useChatRealtimeHandlers({
selectedSession,
currentSessionId,
setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,

View File

@@ -239,6 +239,7 @@ function ChatInterface({
selectedSession,
currentSessionId,
setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,

View File

@@ -1,6 +1,5 @@
import type { ITerminalOptions } from '@xterm/xterm';
export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
export const SHELL_RESTART_DELAY_MS = 200;
export const TERMINAL_INIT_DELAY_MS = 100;
export const TERMINAL_RESIZE_DELAY_MS = 50;

View File

@@ -24,7 +24,6 @@ type UseShellConnectionOptions = {
autoConnect: boolean;
closeSocket: () => void;
clearTerminalScreen: () => void;
setAuthUrl: (nextAuthUrl: string) => void;
onOutputRef?: MutableRefObject<(() => void) | null>;
};
@@ -49,7 +48,6 @@ export function useShellConnection({
autoConnect,
closeSocket,
clearTerminalScreen,
setAuthUrl,
onOutputRef,
}: UseShellConnectionOptions): UseShellConnectionResult {
const [isConnected, setIsConnected] = useState(false);
@@ -100,14 +98,8 @@ export function useShellConnection({
return;
}
if (message.type === 'auth_url' || message.type === 'url_open') {
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
if (nextAuthUrl) {
setAuthUrl(nextAuthUrl);
}
}
},
[handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef],
[handleProcessCompletion, onOutputRef, terminalRef],
);
const connectWebSocket = useCallback(
@@ -133,7 +125,6 @@ export function useShellConnection({
setIsConnected(true);
setIsConnecting(false);
connectingRef.current = false;
setAuthUrl('');
window.setTimeout(() => {
const currentTerminal = terminalRef.current;
@@ -196,7 +187,6 @@ export function useShellConnection({
isPlainShellRef,
selectedProjectRef,
selectedSessionRef,
setAuthUrl,
terminalRef,
wsRef,
],
@@ -225,8 +215,7 @@ export function useShellConnection({
setIsConnecting(false);
connectingRef.current = false;
forceRestartOnInitRef.current = false;
setAuthUrl('');
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
}, [clearTerminalScreen, closeSocket]);
useEffect(() => {
if (

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm';
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { useShellConnection } from './useShellConnection';
import { useShellTerminal } from './useShellTerminal';
@@ -22,15 +23,11 @@ export function useShellRuntime({
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [authUrl, setAuthUrl] = useState('');
const [authUrlVersion, setAuthUrlVersion] = useState(0);
const selectedProjectRef = useRef(selectedProject);
const selectedSessionRef = useRef(selectedSession);
const initialCommandRef = useRef(initialCommand);
const isPlainShellRef = useRef(isPlainShell);
const onProcessCompleteRef = useRef(onProcessComplete);
const authUrlRef = useRef('');
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
// Keep mutable values in refs so websocket handlers always read current data.
@@ -42,12 +39,6 @@ export function useShellRuntime({
onProcessCompleteRef.current = onProcessComplete;
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
authUrlRef.current = nextAuthUrl;
setAuthUrl(nextAuthUrl);
setAuthUrlVersion((previous) => previous + 1);
}, []);
const closeSocket = useCallback(() => {
const activeSocket = wsRef.current;
if (!activeSocket) {
@@ -64,32 +55,6 @@ export function useShellRuntime({
wsRef.current = null;
}, []);
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
if (!url) {
return false;
}
const popup = window.open(url, '_blank');
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore cross-origin restrictions when trying to null opener.
}
return true;
}
return false;
}, []);
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
if (!url) {
return false;
}
return copyTextToClipboard(url);
}, []);
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
terminalContainerRef,
terminalRef,
@@ -98,10 +63,6 @@ export function useShellRuntime({
selectedProject,
minimal,
isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket,
});
@@ -118,7 +79,6 @@ export function useShellRuntime({
autoConnect,
closeSocket,
clearTerminalScreen,
setAuthUrl: setCurrentAuthUrl,
onOutputRef,
});
@@ -156,11 +116,7 @@ export function useShellRuntime({
isConnected,
isInitialized,
isConnecting,
authUrl,
authUrlVersion,
connectToShell,
disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
};
}

View File

@@ -4,15 +4,18 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { WebglAddon } from '@xterm/addon-webgl';
import { Terminal } from '@xterm/xterm';
import type { Project } from '../../../types/app';
import { copyTextToClipboard } from '../../../utils/clipboard';
import {
CODEX_DEVICE_AUTH_URL,
TERMINAL_INIT_DELAY_MS,
TERMINAL_OPTIONS,
TERMINAL_RESIZE_DELAY_MS,
} from '../constants/constants';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { isCodexLoginCommand } from '../utils/auth';
import {
installMobileTerminalSelection,
type MobileTerminalSelectionManager,
} from '../utils/mobileTerminalSelection';
import { sendSocketMessage } from '../utils/socket';
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
@@ -24,10 +27,6 @@ type UseShellTerminalOptions = {
selectedProject: Project | null | undefined;
minimal: boolean;
isRestarting: boolean;
initialCommandRef: MutableRefObject<string | null | undefined>;
isPlainShellRef: MutableRefObject<boolean>;
authUrlRef: MutableRefObject<string>;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
closeSocket: () => void;
};
@@ -45,14 +44,11 @@ export function useShellTerminal({
selectedProject,
minimal,
isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket,
}: UseShellTerminalOptions): UseShellTerminalResult {
const [isInitialized, setIsInitialized] = useState(false);
const resizeTimeoutRef = useRef<number | null>(null);
const mobileSelectionRef = useRef<MobileTerminalSelectionManager | null>(null);
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
const hasSelectedProject = Boolean(selectedProject);
@@ -70,6 +66,11 @@ export function useShellTerminal({
}, [terminalRef]);
const disposeTerminal = useCallback(() => {
if (mobileSelectionRef.current) {
mobileSelectionRef.current.dispose();
mobileSelectionRef.current = null;
}
if (terminalRef.current) {
terminalRef.current.dispose();
terminalRef.current = null;
@@ -80,7 +81,8 @@ export function useShellTerminal({
}, [fitAddonRef, terminalRef]);
useEffect(() => {
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {
const terminalContainer = terminalContainerRef.current;
if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) {
return;
}
@@ -102,7 +104,11 @@ export function useShellTerminal({
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
}
nextTerminal.open(terminalContainerRef.current);
nextTerminal.open(terminalContainer);
mobileSelectionRef.current = installMobileTerminalSelection(
nextTerminal,
terminalContainer,
);
const copyTerminalSelection = async () => {
const selection = nextTerminal.getSelection();
@@ -133,29 +139,9 @@ export function useShellTerminal({
void copyTextToClipboard(selection);
};
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
terminalContainer.addEventListener('copy', handleTerminalCopy);
nextTerminal.attachCustomKeyEventHandler((event) => {
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
? CODEX_DEVICE_AUTH_URL
: authUrlRef.current;
if (
event.type === 'keydown' &&
minimal &&
isPlainShellRef.current &&
activeAuthUrl &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
event.key?.toLowerCase() === 'c'
) {
event.preventDefault();
event.stopPropagation();
void copyAuthUrlToClipboard(activeAuthUrl);
return false;
}
if (
event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) &&
@@ -240,10 +226,10 @@ export function useShellTerminal({
}, TERMINAL_RESIZE_DELAY_MS);
});
resizeObserver.observe(terminalContainerRef.current);
resizeObserver.observe(terminalContainer);
return () => {
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
terminalContainer.removeEventListener('copy', handleTerminalCopy);
resizeObserver.disconnect();
if (resizeTimeoutRef.current !== null) {
window.clearTimeout(resizeTimeoutRef.current);
@@ -254,16 +240,12 @@ export function useShellTerminal({
disposeTerminal();
};
}, [
authUrlRef,
closeSocket,
copyAuthUrlToClipboard,
disposeTerminal,
fitAddonRef,
initialCommandRef,
isPlainShellRef,
isRestarting,
minimal,
hasSelectedProject,
minimal,
selectedProjectKey,
terminalContainerRef,
terminalRef,

View File

@@ -4,8 +4,6 @@ import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app';
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
export type ShellInitMessage = {
type: 'init';
projectPath: string;
@@ -54,7 +52,6 @@ export type ShellSharedRefs = {
wsRef: MutableRefObject<WebSocket | null>;
terminalRef: MutableRefObject<Terminal | null>;
fitAddonRef: MutableRefObject<FitAddon | null>;
authUrlRef: MutableRefObject<string>;
selectedProjectRef: MutableRefObject<Project | null | undefined>;
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
initialCommandRef: MutableRefObject<string | null | undefined>;
@@ -69,10 +66,6 @@ export type UseShellRuntimeResult = {
isConnected: boolean;
isInitialized: boolean;
isConnecting: boolean;
authUrl: string;
authUrlVersion: number;
connectToShell: (options?: { forceRestart?: boolean }) => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
openAuthUrlInBrowser: (url?: string) => boolean;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
};

View File

@@ -1,17 +1,4 @@
import type { ProjectSession } from '../../../types/app';
import { CODEX_DEVICE_AUTH_URL } from '../constants/constants';
export function isCodexLoginCommand(command: string | null | undefined): boolean {
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
}
export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {
if (isCodexLoginCommand(command)) {
return CODEX_DEVICE_AUTH_URL;
}
return authUrl;
}
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
if (!session) {
@@ -21,4 +8,4 @@ export function getSessionDisplayName(session: ProjectSession | null | undefined
return session.__provider === 'cursor'
? session.name || 'Untitled Session'
: session.summary || 'New Session';
}
}

View File

@@ -0,0 +1,637 @@
import type { IDisposable, Terminal } from '@xterm/xterm';
type TerminalCoords = {
col: number;
row: number;
};
type TouchCoords = {
clientX: number;
clientY: number;
};
type CellDimensions = {
width: number;
height: number;
};
type DragHandle = 'start' | 'end';
type TerminalWithRenderService = Terminal & {
_core?: {
_renderService?: {
dimensions?: {
css?: {
cell?: {
width?: number;
height?: number;
};
};
};
};
};
};
export type MobileTerminalSelectionManager = {
dispose: () => void;
updateHandles: () => void;
};
const LONG_PRESS_MS = 600;
const MOVE_THRESHOLD_PX = 8;
const HANDLE_SIZE_PX = 22;
const FINGER_OFFSET_PX = 40;
function isTouchSelectionEnvironment(): boolean {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return false;
}
return (
navigator.maxTouchPoints > 0 ||
'ontouchstart' in window ||
window.matchMedia?.('(pointer: coarse)').matches === true
);
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function getDistance(start: TouchCoords, end: TouchCoords): number {
return Math.hypot(end.clientX - start.clientX, end.clientY - start.clientY);
}
class ShellMobileSelectionCore implements MobileTerminalSelectionManager {
private readonly terminal: Terminal;
private readonly terminalContent: HTMLElement;
private readonly overlay: HTMLDivElement;
private readonly startHandle: HTMLDivElement;
private readonly endHandle: HTMLDivElement;
private readonly disposables: IDisposable[] = [];
private readonly originalPosition: string;
private didSetPosition = false;
private isDestroyed = false;
private isSelecting = false;
private isHandleDragging = false;
private dragHandle: DragHandle | null = null;
private selectionStart: TerminalCoords | null = null;
private selectionEnd: TerminalCoords | null = null;
private touchStart: TouchCoords | null = null;
private pendingClearTouch: { point: TouchCoords; moved: boolean } | null = null;
private tapHoldTimeout: number | null = null;
private cellDimensions: CellDimensions = { width: 0, height: 0 };
constructor(terminal: Terminal, terminalContent: HTMLElement) {
this.terminal = terminal;
this.terminalContent = terminalContent;
this.originalPosition = terminalContent.style.position;
if (window.getComputedStyle(terminalContent).position === 'static') {
terminalContent.style.position = 'relative';
this.didSetPosition = true;
}
this.overlay = this.createSelectionOverlay();
this.startHandle = this.createHandle('start');
this.endHandle = this.createHandle('end');
this.overlay.append(this.startHandle, this.endHandle);
this.terminalContent.appendChild(this.overlay);
this.attachEventListeners();
this.updateCellDimensions();
}
private createSelectionOverlay(): HTMLDivElement {
const overlay = document.createElement('div');
overlay.className = 'shell-mobile-selection-overlay';
overlay.style.position = 'absolute';
overlay.style.inset = '0';
overlay.style.overflow = 'hidden';
overlay.style.pointerEvents = 'none';
overlay.style.zIndex = '30';
return overlay;
}
private createHandle(type: DragHandle): HTMLDivElement {
const handle = document.createElement('div');
handle.className = `shell-mobile-selection-handle shell-mobile-selection-handle-${type}`;
handle.dataset.handleType = type;
handle.style.position = 'absolute';
handle.style.width = `${HANDLE_SIZE_PX}px`;
handle.style.height = `${HANDLE_SIZE_PX}px`;
handle.style.borderRadius = '50%';
handle.style.background = '#3b82f6';
handle.style.border = '2px solid #fff';
handle.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
handle.style.display = 'none';
handle.style.pointerEvents = 'auto';
handle.style.touchAction = 'none';
handle.style.zIndex = '31';
return handle;
}
private attachEventListeners(): void {
if (!this.terminal.element) {
return;
}
this.terminal.element.addEventListener('touchstart', this.onTerminalTouchStart, {
passive: false,
});
this.terminal.element.addEventListener('touchmove', this.onTerminalTouchMove, {
passive: false,
});
this.terminal.element.addEventListener('touchend', this.onTerminalTouchEnd, {
passive: false,
});
this.terminal.element.addEventListener('touchcancel', this.onTerminalTouchCancel, {
passive: false,
});
this.startHandle.addEventListener('touchstart', this.onHandleTouchStart, { passive: false });
this.startHandle.addEventListener('touchmove', this.onHandleTouchMove, { passive: false });
this.startHandle.addEventListener('touchend', this.onHandleTouchEnd, { passive: false });
this.startHandle.addEventListener('touchcancel', this.onHandleTouchEnd, { passive: false });
this.endHandle.addEventListener('touchstart', this.onHandleTouchStart, { passive: false });
this.endHandle.addEventListener('touchmove', this.onHandleTouchMove, { passive: false });
this.endHandle.addEventListener('touchend', this.onHandleTouchEnd, { passive: false });
this.endHandle.addEventListener('touchcancel', this.onHandleTouchEnd, { passive: false });
document.addEventListener('touchstart', this.onDocumentTouchStart, { passive: true });
this.disposables.push(
this.terminal.onSelectionChange(this.onSelectionChange),
this.terminal.onResize(this.onTerminalResize),
this.terminal.onScroll(this.onTerminalScroll),
);
}
private onTerminalTouchStart = (event: TouchEvent): void => {
if (event.touches.length !== 1) {
this.clearTapHoldTimeout();
return;
}
const touch = this.toTouchCoords(event.touches[0]);
this.touchStart = touch;
if (this.isSelecting) {
this.pendingClearTouch = { point: touch, moved: false };
return;
}
this.clearTapHoldTimeout();
this.tapHoldTimeout = window.setTimeout(() => {
this.tapHoldTimeout = null;
this.startSelection(touch);
}, LONG_PRESS_MS);
};
private onTerminalTouchMove = (event: TouchEvent): void => {
if (event.touches.length !== 1) {
this.clearTapHoldTimeout();
return;
}
const touch = this.toTouchCoords(event.touches[0]);
const touchStart = this.touchStart;
if (this.pendingClearTouch) {
this.pendingClearTouch.moved =
this.pendingClearTouch.moved ||
getDistance(this.pendingClearTouch.point, touch) > MOVE_THRESHOLD_PX;
return;
}
if (!touchStart) {
return;
}
const moved = getDistance(touchStart, touch) > MOVE_THRESHOLD_PX;
if (moved) {
this.clearTapHoldTimeout();
}
if (this.isSelecting && !this.isHandleDragging) {
event.preventDefault();
this.extendSelection(touch);
}
};
private onTerminalTouchEnd = (): void => {
this.clearTapHoldTimeout();
this.touchStart = null;
if (!this.pendingClearTouch) {
return;
}
const shouldClear = this.isSelecting && !this.pendingClearTouch.moved && !this.isHandleDragging;
this.pendingClearTouch = null;
if (shouldClear) {
this.clearSelection();
}
};
private onTerminalTouchCancel = (): void => {
this.clearTapHoldTimeout();
this.touchStart = null;
this.pendingClearTouch = null;
};
private onHandleTouchStart = (event: TouchEvent): void => {
event.preventDefault();
event.stopPropagation();
if (event.touches.length !== 1) {
return;
}
const target = event.currentTarget as HTMLElement;
this.dragHandle = target.dataset.handleType === 'start' ? 'start' : 'end';
this.isHandleDragging = true;
this.pendingClearTouch = null;
};
private onHandleTouchMove = (event: TouchEvent): void => {
if (!this.isHandleDragging || !this.dragHandle || event.touches.length !== 1) {
return;
}
event.preventDefault();
event.stopPropagation();
const touch = this.toTouchCoords(event.touches[0]);
const adjustedTouch = {
clientX: touch.clientX,
clientY: touch.clientY - FINGER_OFFSET_PX,
};
const coords = this.touchToTerminalCoords(adjustedTouch);
if (!coords) {
return;
}
if (this.dragHandle === 'start') {
this.selectionStart = coords;
} else {
this.selectionEnd = coords;
}
this.swapHandlesIfNeeded();
this.updateSelection();
};
private onHandleTouchEnd = (event: TouchEvent): void => {
if (!this.isHandleDragging) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isHandleDragging = false;
this.dragHandle = null;
};
private onSelectionChange = (): void => {
if (!this.isSelecting) {
return;
}
if (!this.terminal.hasSelection()) {
this.resetSelectionState();
return;
}
this.updateHandles();
};
private onTerminalResize = (): void => {
this.updateCellDimensions();
this.updateHandles();
};
private onTerminalScroll = (): void => {
this.updateHandles();
};
private onDocumentTouchStart = (event: TouchEvent): void => {
if (!this.isSelecting || !event.target) {
return;
}
if (this.terminalContent.contains(event.target as Node)) {
return;
}
this.clearSelection();
};
private startSelection(touch: TouchCoords): void {
const coords = this.touchToTerminalCoords(touch);
if (!coords) {
return;
}
const wordBounds = this.getWordBoundsAt(coords);
this.selectionStart = wordBounds?.start ?? coords;
this.selectionEnd = wordBounds?.end ?? coords;
this.isSelecting = true;
this.updateSelection();
this.showHandles();
}
private extendSelection(touch: TouchCoords): void {
const coords = this.touchToTerminalCoords(touch);
if (!coords) {
return;
}
this.selectionEnd = coords;
this.updateSelection();
}
private updateSelection(): void {
if (!this.selectionStart || !this.selectionEnd) {
return;
}
const { start, end } = this.getOrderedSelection();
const length = this.calculateSelectionLength(start, end);
if (length <= 0) {
return;
}
this.terminal.select(start.col, start.row, length);
this.updateHandles();
}
private calculateSelectionLength(start: TerminalCoords, end: TerminalCoords): number {
if (start.row === end.row) {
return end.col - start.col + 1;
}
return (end.row - start.row) * this.terminal.cols - start.col + end.col + 1;
}
private getOrderedSelection(): { start: TerminalCoords; end: TerminalCoords } {
const start = this.selectionStart;
const end = this.selectionEnd;
if (!start || !end) {
throw new Error('Cannot order empty terminal selection');
}
if (start.row < end.row || (start.row === end.row && start.col <= end.col)) {
return { start, end };
}
return { start: end, end: start };
}
private swapHandlesIfNeeded(): void {
if (!this.selectionStart || !this.selectionEnd || !this.dragHandle) {
return;
}
const { start, end } = this.getOrderedSelection();
if (start === this.selectionStart && end === this.selectionEnd) {
return;
}
this.selectionStart = start;
this.selectionEnd = end;
this.dragHandle = this.dragHandle === 'start' ? 'end' : 'start';
}
private showHandles(): void {
this.startHandle.style.display = 'block';
this.endHandle.style.display = 'block';
this.updateHandles();
}
private hideHandles(): void {
this.startHandle.style.display = 'none';
this.endHandle.style.display = 'none';
}
updateHandles(): void {
if (!this.isSelecting || !this.selectionStart || !this.selectionEnd) {
this.hideHandles();
return;
}
const { start, end } = this.getOrderedSelection();
const startPosition = this.terminalCoordsToPixels(start);
const endPosition = this.terminalCoordsToPixels(end);
if (startPosition) {
this.startHandle.style.display = 'block';
this.startHandle.style.left = `${startPosition.x - HANDLE_SIZE_PX / 2}px`;
this.startHandle.style.top = `${startPosition.y + this.cellDimensions.height + 4}px`;
} else {
this.startHandle.style.display = 'none';
}
if (endPosition) {
this.endHandle.style.display = 'block';
this.endHandle.style.left = `${endPosition.x + this.cellDimensions.width - HANDLE_SIZE_PX / 2}px`;
this.endHandle.style.top = `${endPosition.y + this.cellDimensions.height + 4}px`;
} else {
this.endHandle.style.display = 'none';
}
}
private clearSelection(): void {
this.terminal.clearSelection();
this.resetSelectionState();
}
private resetSelectionState(): void {
this.isSelecting = false;
this.isHandleDragging = false;
this.dragHandle = null;
this.selectionStart = null;
this.selectionEnd = null;
this.pendingClearTouch = null;
this.touchStart = null;
this.hideHandles();
this.clearTapHoldTimeout();
}
private touchToTerminalCoords(touch: TouchCoords): TerminalCoords | null {
const screenElement = this.getTerminalScreenElement();
if (!screenElement) {
return null;
}
const rect = screenElement.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
return null;
}
this.updateCellDimensions();
if (!this.cellDimensions.width || !this.cellDimensions.height) {
return null;
}
const col = clamp(Math.floor(x / this.cellDimensions.width), 0, this.terminal.cols - 1);
const row = Math.floor(y / this.cellDimensions.height) + this.terminal.buffer.active.viewportY;
return {
col,
row: Math.max(0, row),
};
}
private terminalCoordsToPixels(coords: TerminalCoords): { x: number; y: number } | null {
const screenElement = this.getTerminalScreenElement();
if (!screenElement) {
return null;
}
this.updateCellDimensions();
const visibleRow = coords.row - this.terminal.buffer.active.viewportY;
if (visibleRow < 0 || visibleRow >= this.terminal.rows) {
return null;
}
const screenRect = screenElement.getBoundingClientRect();
const containerRect = this.terminalContent.getBoundingClientRect();
return {
x: screenRect.left - containerRect.left + coords.col * this.cellDimensions.width,
y: screenRect.top - containerRect.top + visibleRow * this.cellDimensions.height,
};
}
private updateCellDimensions(): void {
const renderCell = (this.terminal as TerminalWithRenderService)._core?._renderService
?.dimensions?.css?.cell;
if (renderCell?.width && renderCell.height) {
this.cellDimensions = {
width: renderCell.width,
height: renderCell.height,
};
return;
}
const screenElement = this.getTerminalScreenElement();
const rect = screenElement?.getBoundingClientRect();
if (!rect || !this.terminal.cols || !this.terminal.rows) {
this.cellDimensions = { width: 0, height: 0 };
return;
}
this.cellDimensions = {
width: rect.width / this.terminal.cols,
height: rect.height / this.terminal.rows,
};
}
private getWordBoundsAt(coords: TerminalCoords): {
start: TerminalCoords;
end: TerminalCoords;
} | null {
const line = this.terminal.buffer.active.getLine(coords.row);
if (!line) {
return null;
}
const lineText = line.translateToString(false);
if (!lineText || coords.col >= lineText.length || /\s/.test(lineText[coords.col])) {
return null;
}
let startCol = coords.col;
let endCol = coords.col;
while (startCol > 0 && !/\s/.test(lineText[startCol - 1])) {
startCol--;
}
while (endCol < lineText.length - 1 && !/\s/.test(lineText[endCol + 1])) {
endCol++;
}
return {
start: { row: coords.row, col: startCol },
end: { row: coords.row, col: endCol },
};
}
private getTerminalScreenElement(): HTMLElement | null {
return (
this.terminal.element?.querySelector<HTMLElement>('.xterm-screen') ??
this.terminal.element ??
null
);
}
private toTouchCoords(touch: Touch): TouchCoords {
return {
clientX: touch.clientX,
clientY: touch.clientY,
};
}
private clearTapHoldTimeout(): void {
if (this.tapHoldTimeout === null) {
return;
}
window.clearTimeout(this.tapHoldTimeout);
this.tapHoldTimeout = null;
}
dispose(): void {
if (this.isDestroyed) {
return;
}
this.isDestroyed = true;
this.clearTapHoldTimeout();
this.terminal.element?.removeEventListener('touchstart', this.onTerminalTouchStart);
this.terminal.element?.removeEventListener('touchmove', this.onTerminalTouchMove);
this.terminal.element?.removeEventListener('touchend', this.onTerminalTouchEnd);
this.terminal.element?.removeEventListener('touchcancel', this.onTerminalTouchCancel);
this.startHandle.removeEventListener('touchstart', this.onHandleTouchStart);
this.startHandle.removeEventListener('touchmove', this.onHandleTouchMove);
this.startHandle.removeEventListener('touchend', this.onHandleTouchEnd);
this.startHandle.removeEventListener('touchcancel', this.onHandleTouchEnd);
this.endHandle.removeEventListener('touchstart', this.onHandleTouchStart);
this.endHandle.removeEventListener('touchmove', this.onHandleTouchMove);
this.endHandle.removeEventListener('touchend', this.onHandleTouchEnd);
this.endHandle.removeEventListener('touchcancel', this.onHandleTouchEnd);
document.removeEventListener('touchstart', this.onDocumentTouchStart);
this.disposables.forEach((disposable) => disposable.dispose());
this.disposables.length = 0;
this.overlay.remove();
if (this.didSetPosition) {
this.terminalContent.style.position = this.originalPosition;
}
}
}
export function installMobileTerminalSelection(
terminal: Terminal,
terminalContent: HTMLElement,
): MobileTerminalSelectionManager | null {
if (!isTouchSelectionEnvironment() || !terminal.element) {
return null;
}
return new ShellMobileSelectionCore(terminal, terminalContent);
}

View File

@@ -59,12 +59,8 @@ export default function Shell({
isConnected,
isInitialized,
isConnecting,
authUrl,
authUrlVersion,
connectToShell,
disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
} = useShellRuntime({
selectedProject,
selectedSession,
@@ -243,15 +239,7 @@ export default function Shell({
if (minimal) {
return (
<>
<ShellMinimalView
terminalContainerRef={terminalContainerRef}
authUrl={authUrl}
authUrlVersion={authUrlVersion}
initialCommand={initialCommand}
isConnected={isConnected}
openAuthUrlInBrowser={openAuthUrlInBrowser}
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
/>
<ShellMinimalView terminalContainerRef={terminalContainerRef} />
<TerminalShortcutsPanel
wsRef={wsRef}
terminalRef={terminalRef}

View File

@@ -1,45 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import type { RefObject } from 'react';
import type { AuthCopyStatus } from '../../types/types';
import { resolveAuthUrlForDisplay } from '../../utils/auth';
type ShellMinimalViewProps = {
terminalContainerRef: RefObject<HTMLDivElement>;
authUrl: string;
authUrlVersion: number;
initialCommand: string | null | undefined;
isConnected: boolean;
openAuthUrlInBrowser: (url: string) => boolean;
copyAuthUrlToClipboard: (url: string) => Promise<boolean>;
};
export default function ShellMinimalView({
terminalContainerRef,
authUrl,
authUrlVersion,
initialCommand,
isConnected,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
}: ShellMinimalViewProps) {
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState<AuthCopyStatus>('idle');
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
const displayAuthUrl = useMemo(
() => resolveAuthUrlForDisplay(initialCommand, authUrl),
[authUrl, initialCommand],
);
// Keep auth panel UI state local to minimal mode and reset it when connection/url changes.
useEffect(() => {
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}, [authUrlVersion, displayAuthUrl, isConnected]);
const hasAuthUrl = Boolean(displayAuthUrl);
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
return (
<div className="relative h-full w-full bg-gray-900">
<div
@@ -47,67 +14,6 @@ export default function ShellMinimalView({
className="h-full w-full focus:outline-none"
style={{ outline: 'none' }}
/>
{showMobileAuthPanel && (
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
<button
type="button"
onClick={() => setIsAuthPanelHidden(true)}
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
>
Hide
</button>
</div>
<input
type="text"
value={displayAuthUrl}
readOnly
onClick={(event) => event.currentTarget.select()}
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
aria-label="Authentication URL"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
openAuthUrlInBrowser(displayAuthUrl);
}}
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
>
Open URL
</button>
<button
type="button"
onClick={async () => {
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
}}
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
>
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
</button>
</div>
</div>
</div>
)}
{showMobileAuthPanelToggle && (
<div className="absolute bottom-14 right-3 z-20 md:hidden">
<button
type="button"
onClick={() => setIsAuthPanelHidden(false)}
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
>
Show login URL
</button>
</div>
)}
</div>
);
}

View File

@@ -43,7 +43,7 @@ function Sidebar({
}: SidebarProps) {
const { t } = useTranslation(['sidebar', 'common']);
const { isPWA } = useDeviceSettings({ trackMobile: false });
const { updateAvailable, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck(
const { updateAvailable, restartRequired, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck(
'siteboon',
'claudecodeui',
);
@@ -224,6 +224,7 @@ function Sidebar({
onExpand={handleExpandSidebar}
onShowSettings={onShowSettings}
updateAvailable={updateAvailable}
restartRequired={restartRequired}
onShowVersionModal={() => setShowVersionModal(true)}
t={t}
/>
@@ -296,6 +297,7 @@ function Sidebar({
onCreateProject={() => setShowNewProject(true)}
onCollapseSidebar={handleCollapseSidebar}
updateAvailable={updateAvailable}
restartRequired={restartRequired}
releaseInfo={releaseInfo}
latestVersion={latestVersion}
currentVersion={currentVersion}

View File

@@ -1,4 +1,4 @@
import { Settings, Sparkles, PanelLeftOpen, Bug } from 'lucide-react';
import { Settings, Sparkles, PanelLeftOpen, Bug, AlertTriangle } from 'lucide-react';
import type { TFunction } from 'i18next';
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
@@ -16,6 +16,7 @@ type SidebarCollapsedProps = {
onExpand: () => void;
onShowSettings: () => void;
updateAvailable: boolean;
restartRequired: boolean;
onShowVersionModal: () => void;
t: TFunction;
};
@@ -24,6 +25,7 @@ export default function SidebarCollapsed({
onExpand,
onShowSettings,
updateAvailable,
restartRequired,
onShowVersionModal,
t,
}: SidebarCollapsedProps) {
@@ -75,6 +77,18 @@ export default function SidebarCollapsed({
<DiscordIcon className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</a>
{/* Restart-required indicator */}
{restartRequired && (
<div
className="relative flex h-8 w-8 items-center justify-center rounded-lg"
aria-label={t('version.restartRequired')}
title={t('version.restartRequired')}
>
<AlertTriangle className="h-4 w-4 text-amber-500" />
<span className="absolute right-1.5 top-1.5 h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500" />
</div>
)}
{/* Update indicator */}
{updateAvailable && (
<button

View File

@@ -141,6 +141,7 @@ type SidebarContentProps = {
onCreateProject: () => void;
onCollapseSidebar: () => void;
updateAvailable: boolean;
restartRequired: boolean;
releaseInfo: ReleaseInfo | null;
latestVersion: string | null;
currentVersion: string;
@@ -178,6 +179,7 @@ export default function SidebarContent({
onCreateProject,
onCollapseSidebar,
updateAvailable,
restartRequired,
releaseInfo,
latestVersion,
currentVersion,
@@ -553,6 +555,7 @@ export default function SidebarContent({
<SidebarFooter
updateAvailable={updateAvailable}
restartRequired={restartRequired}
releaseInfo={releaseInfo}
latestVersion={latestVersion}
currentVersion={currentVersion}

View File

@@ -1,4 +1,4 @@
import { Settings, ArrowUpCircle, Bug } from 'lucide-react';
import { Settings, ArrowUpCircle, Bug, AlertTriangle } from 'lucide-react';
import type { TFunction } from 'i18next';
import { IS_PLATFORM } from '../../../../constants/config';
import type { ReleaseInfo } from '../../../../types/sharedTypes';
@@ -18,6 +18,7 @@ function DiscordIcon({ className }: { className?: string }) {
type SidebarFooterProps = {
updateAvailable: boolean;
restartRequired: boolean;
releaseInfo: ReleaseInfo | null;
latestVersion: string | null;
currentVersion: string;
@@ -28,6 +29,7 @@ type SidebarFooterProps = {
export default function SidebarFooter({
updateAvailable,
restartRequired,
releaseInfo,
latestVersion,
currentVersion,
@@ -37,6 +39,22 @@ export default function SidebarFooter({
}: SidebarFooterProps) {
return (
<div className="flex-shrink-0" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0)' }}>
{/* Restart-required banner: the running server version differs from the
installed/frontend version (updated but not restarted). */}
{restartRequired && (
<>
<div className="nav-divider" />
<div className="px-2 py-1.5 md:px-2 md:py-1.5">
<div className="flex items-center gap-2.5 rounded-lg border border-amber-300/60 bg-amber-50/80 px-2.5 py-2 dark:border-amber-700/40 dark:bg-amber-900/15">
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-amber-500 dark:text-amber-400" />
<span className="min-w-0 flex-1 text-xs font-medium text-amber-700 dark:text-amber-300">
{t('version.restartRequired')}
</span>
</div>
</div>
</>
)}
{/* Update banner */}
{updateAvailable && (
<>

View File

@@ -28,20 +28,31 @@ export const useVersionCheck = (owner: string, repo: string) => {
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [releaseInfo, setReleaseInfo] = useState<ReleaseInfo | null>(null);
const [installMode, setInstallMode] = useState<InstallMode>('git');
const [runningVersion, setRunningVersion] = useState<string | null>(null);
const [restartRequired, setRestartRequired] = useState(false);
useEffect(() => {
const fetchInstallMode = async () => {
const fetchHealth = async () => {
try {
const response = await fetch('/health');
const data = await response.json();
if (data.installMode === 'npm' || data.installMode === 'git') {
setInstallMode(data.installMode);
}
// `data.version` is the version the server process is actually running.
// This module's `version` is baked into the frontend bundle at build
// time, so it reflects the installed (on-disk) package. If they differ,
// the package was updated but the server process was not restarted, and
// DB-backed actions may silently fail until it is.
if (typeof data.version === 'string' && data.version.length > 0) {
setRunningVersion(data.version);
setRestartRequired(data.version !== version);
}
} catch {
// Default to git on error
// Default to git / no restart hint on error
}
};
fetchInstallMode();
fetchHealth();
}, []);
useEffect(() => {
@@ -84,5 +95,5 @@ export const useVersionCheck = (owner: string, repo: string) => {
return () => clearInterval(interval);
}, [owner, repo]);
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode };
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode, runningVersion, restartRequired };
};

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Fehler beim Wiederherstellen der Sitzung. Bitte erneut versuchen."
},
"version": {
"updateAvailable": "Update verfügbar"
"updateAvailable": "Update verfügbar",
"restartRequired": "Update installiert zum Anwenden Server neu starten"
},
"search": {
"modeProjects": "Projekte",

View File

@@ -114,7 +114,7 @@
},
"sound": {
"title": "Sound",
"description": "Play a short tone when a chat run finishes.",
"description": "Play a short tone when a chat run finishes or needs tool approval.",
"enabled": "Enabled",
"test": "Test sound"
},

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Error restoring session. Please try again."
},
"version": {
"updateAvailable": "Update available"
"updateAvailable": "Update available",
"restartRequired": "Update installed — restart the server to apply"
},
"search": {
"modeProjects": "Projects",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Erreur lors de la restauration de la session. Veuillez réessayer."
},
"version": {
"updateAvailable": "Mise à jour disponible"
"updateAvailable": "Mise à jour disponible",
"restartRequired": "Mise à jour installée — redémarrez le serveur pour l'appliquer"
},
"search": {
"modeProjects": "Projets",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Errore durante il ripristino della sessione. Riprova."
},
"version": {
"updateAvailable": "Aggiornamento disponibile"
"updateAvailable": "Aggiornamento disponibile",
"restartRequired": "Aggiornamento installato — riavvia il server per applicarlo"
},
"search": {
"modeProjects": "Progetti",

View File

@@ -114,7 +114,8 @@
"restoreSessionError": "セッションの復元でエラーが発生しました。もう一度お試しください。"
},
"version": {
"updateAvailable": "アップデートあり"
"updateAvailable": "アップデートあり",
"restartRequired": "更新が適用されていません。サーバーを再起動してください"
},
"deleteConfirmation": {
"deleteProject": "プロジェクトを除去",

View File

@@ -114,7 +114,8 @@
"restoreSessionError": "세션 복원 오류. 다시 시도해주세요."
},
"version": {
"updateAvailable": "업데이트 가능"
"updateAvailable": "업데이트 가능",
"restartRequired": "업데이트가 설치됨 — 적용하려면 서버를 재시작하세요"
},
"deleteConfirmation": {
"deleteProject": "프로젝트 제거",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Ошибка при восстановлении сеанса. Попробуйте снова."
},
"version": {
"updateAvailable": "Доступно обновление"
"updateAvailable": "Доступно обновление",
"restartRequired": "Обновление установлено — перезапустите сервер для применения"
},
"search": {
"modeProjects": "Проекты",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Oturum geri yüklenirken hata oluştu. Lütfen tekrar dene."
},
"version": {
"updateAvailable": "Güncelleme mevcut"
"updateAvailable": "Güncelleme mevcut",
"restartRequired": "Güncelleme yüklendi — uygulamak için sunucuyu yeniden başlatın"
},
"search": {
"modeProjects": "Projeler",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "恢复会话时出错,请重试。"
},
"version": {
"updateAvailable": "有可用更新"
"updateAvailable": "有可用更新",
"restartRequired": "已安装更新 — 请重启服务器以生效"
},
"search": {
"modeProjects": "项目",

View File

@@ -114,7 +114,8 @@
"restoreSessionError": "還原工作階段時出錯,請重試。"
},
"version": {
"updateAvailable": "有可用更新"
"updateAvailable": "有可用更新",
"restartRequired": "已安裝更新 — 請重新啟動伺服器以套用"
},
"search": {
"modeProjects": "專案",

View File

@@ -58,7 +58,7 @@ const playTone = (
oscillator.stop(startsAt + duration + 0.02);
};
export const playChatCompletionSound = async ({ force = false } = {}): Promise<void> => {
export const playNotificationSound = async ({ force = false } = {}): Promise<void> => {
if (!force && !isNotificationSoundEnabled()) {
return;
}
@@ -81,3 +81,5 @@ export const playChatCompletionSound = async ({ force = false } = {}): Promise<v
console.warn('Unable to play notification sound:', error);
}
};
export const playChatCompletionSound = (options = {}): Promise<void> => playNotificationSound(options);