Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	server/index.js
This commit is contained in:
Simos Mikelatos
2026-06-29 07:43:33 +00:00
139 changed files with 15972 additions and 690 deletions

View File

@@ -204,6 +204,8 @@ export function useChatComposerState({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputHighlightRef = useRef<HTMLDivElement>(null);
const textareaLineHeightRef = useRef<number | null>(null);
const lastAutosizedInputRef = useRef<string | null>(null);
const handleSubmitRef = useRef<
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null
>(null);
@@ -457,6 +459,22 @@ export function useChatComposerState({
inputHighlightRef.current.scrollLeft = target.scrollLeft;
}, []);
const resizeTextarea = useCallback((target: HTMLTextAreaElement) => {
target.style.height = 'auto';
const nextHeight = Math.max(22, target.scrollHeight);
target.style.height = `${nextHeight}px`;
let lineHeight = textareaLineHeightRef.current;
if (!lineHeight) {
lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
textareaLineHeightRef.current = Number.isFinite(lineHeight) ? lineHeight : 24;
}
const expanded = nextHeight > (textareaLineHeightRef.current || 24) * 2;
setIsTextareaExpanded((previous) => previous === expanded ? previous : expanded);
lastAutosizedInputRef.current = target.value;
}, []);
const handleImageFiles = useCallback((files: File[]) => {
const validFiles = files.filter((file) => {
try {
@@ -817,13 +835,13 @@ export function useChatComposerState({
if (!textareaRef.current) {
return;
}
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
setIsTextareaExpanded(expanded);
}, [input]);
if (lastAutosizedInputRef.current === input) {
return;
}
// Re-run for restored drafts and programmatic input changes. User typing is
// already resized in onInput, so this avoids doing the same forced layout twice.
resizeTextarea(textareaRef.current);
}, [input, resizeTextarea]);
useEffect(() => {
if (!textareaRef.current || input.trim()) {
@@ -905,15 +923,11 @@ export function useChatComposerState({
const handleTextareaInput = useCallback(
(event: FormEvent<HTMLTextAreaElement>) => {
const target = event.currentTarget;
target.style.height = 'auto';
target.style.height = `${Math.max(22, target.scrollHeight)}px`;
resizeTextarea(target);
setCursorPosition(target.selectionStart);
syncInputOverlayScroll(target);
const lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
setIsTextareaExpanded(target.scrollHeight > lineHeight * 2);
},
[setCursorPosition, syncInputOverlayScroll],
[resizeTextarea, setCursorPosition, syncInputOverlayScroll],
);
const handleClearInput = useCallback(() => {

View File

@@ -207,6 +207,15 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
break;
}
// A result with a toolId but no matching tool_use in the loaded set is
// almost always a tool_use/tool_result pair split across a pagination
// boundary (older page not loaded yet). Rendering its raw content here
// produces an unstyled dump that "fixes itself" once the older page
// loads; skip it and let it attach to its tool_use when that arrives.
if (msg.toolId) {
break;
}
const content = formatToolResultContent(msg.content || '');
if (!content.trim()) {
break;

View File

@@ -4,7 +4,7 @@ import type { Project } from '../../../types/app';
import type { SubagentChildTool } from '../types/types';
import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import { OneLineDisplay, BashCommandDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import { PlanDisplay } from './components/PlanDisplay';
import { ToolStatusBadge } from './components/ToolStatusBadge';
import type { ToolStatus } from './components/ToolStatusBadge';
@@ -125,6 +125,39 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
if (!displayConfig) return null;
// Bash renders as a Codex-style command row: the command on a single line with
// a chevron that expands to show the output inline. The combined view lives on
// the input render; the separate result section is suppressed in MessageComponent.
if (toolName === 'Bash' && mode === 'input') {
const command = typeof parsedData === 'object' && parsedData !== null && 'command' in parsedData
? String(parsedData.command || '')
: typeof toolInput === 'string'
? toolInput
: typeof rawToolInput === 'string'
? rawToolInput
: '';
const description = typeof parsedData === 'object' && parsedData !== null && 'description' in parsedData
? String(parsedData.description || '')
: undefined;
const output = typeof toolResult?.content === 'string'
? toolResult.content
: toolResult?.content != null
? String(toolResult.content)
: '';
return (
<BashCommandDisplay
command={command}
description={description}
output={output}
isError={Boolean(toolResult?.isError)}
status={toolStatus !== 'completed' ? toolStatus : undefined}
// Commands stay collapsed by default (even consecutive ones); only
// failures auto-expand so they remain visible.
defaultOpen={false}
/>
);
}
if (displayConfig.type === 'one-line') {
const value = displayConfig.getValue?.(parsedData) || '';
const secondary = displayConfig.getSecondary?.(parsedData);

View File

@@ -0,0 +1,156 @@
import React, { useEffect, useRef, useState } from 'react';
import { ChevronRight, Copy, Check } from 'lucide-react';
import { cn } from '../../../../lib/utils';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import { ToolStatusBadge } from './ToolStatusBadge';
import type { ToolStatus } from './ToolStatusBadge';
interface BashCommandDisplayProps {
command: string;
description?: string;
/** Combined stdout/stderr from the tool result (empty while running). */
output?: string;
isError?: boolean;
status?: ToolStatus;
defaultOpen?: boolean;
}
/**
* Codex-in-VSCode style command row: a compact, single-line command with a
* chevron on the left. When the command produced output, the row becomes a
* dropdown that expands to reveal the output inline. Theme-integrated surfaces
* keep it clean in both light and dark mode; consecutive commands stack tightly
* into a clean list.
*/
export const BashCommandDisplay: React.FC<BashCommandDisplayProps> = ({
command,
description,
output,
isError = false,
status,
defaultOpen = false,
}) => {
const trimmedOutput = (output || '').replace(/\s+$/, '');
const hasOutput = trimmedOutput.length > 0;
const outputLineCount = hasOutput ? trimmedOutput.split('\n').length : 0;
const isRunning = status === 'running';
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
// Output (and errors) often arrive after this component first mounts, so apply
// the auto-open intent once when there is finally something to show. After that
// the user is in control of the toggle.
const autoAppliedRef = useRef(false);
useEffect(() => {
if (!autoAppliedRef.current && hasOutput && (defaultOpen || isError)) {
autoAppliedRef.current = true;
setOpen(true);
}
}, [hasOutput, defaultOpen, isError]);
const toggle = () => {
if (hasOutput) {
setOpen((prev) => !prev);
}
};
const handleCopy = async (event: React.MouseEvent) => {
event.stopPropagation();
const didCopy = await copyTextToClipboard(command);
if (!didCopy) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div
className={cn(
'group/cmd overflow-hidden rounded-lg border bg-muted/40 backdrop-blur-sm transition-all duration-200',
isError ? 'border-red-500/30' : 'border-border/60',
hasOutput && !open && 'hover:border-border hover:bg-muted/60',
open && 'bg-muted/50 shadow-sm',
)}
>
{/* Command header — clickable when there is output to expand */}
<div
role={hasOutput ? 'button' : undefined}
tabIndex={hasOutput ? 0 : undefined}
aria-expanded={hasOutput ? open : undefined}
onClick={toggle}
onKeyDown={(event) => {
if (hasOutput && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
toggle();
}
}}
className={cn(
'flex items-center gap-2 px-2.5 py-1.5 outline-none',
hasOutput && 'cursor-pointer focus-visible:ring-1 focus-visible:ring-ring',
)}
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/70 transition-transform duration-200',
open && 'rotate-90',
!hasOutput && 'opacity-0',
)}
/>
<span className="flex-shrink-0 select-none font-mono text-xs font-semibold text-emerald-500 dark:text-emerald-400">
$
</span>
<code
className={cn(
'min-w-0 flex-1 font-mono text-xs text-foreground',
open ? 'whitespace-pre-wrap break-all' : 'truncate',
)}
>
{command}
</code>
{isRunning && (
<span className="h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border-[1.5px] border-muted-foreground/30 border-t-emerald-400" />
)}
{status && status !== 'running' && <ToolStatusBadge status={status} className="flex-shrink-0" />}
{!open && hasOutput && !isRunning && (
<span className="flex-shrink-0 text-[10px] tabular-nums text-muted-foreground/70 transition-opacity group-hover/cmd:opacity-0">
{outputLineCount} {outputLineCount === 1 ? 'line' : 'lines'}
</span>
)}
<button
onClick={handleCopy}
onKeyDown={(event) => event.stopPropagation()}
className="flex-shrink-0 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-all hover:bg-foreground/10 hover:text-foreground focus:opacity-100 group-hover/cmd:opacity-100"
title="Copy command"
aria-label="Copy command"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
{description && !open && (
<div className="truncate px-2.5 pb-1.5 pl-[2.4rem] text-[11px] italic text-muted-foreground/70">
{description}
</div>
)}
{/* Expanded output */}
{open && hasOutput && (
<div className="settings-content-enter border-t border-border/50 bg-background/50">
{description && (
<div className="px-3 pt-2 text-[11px] italic text-muted-foreground/70">{description}</div>
)}
<pre
className={cn(
'max-h-80 overflow-auto whitespace-pre-wrap break-all px-3 py-2 font-mono text-xs leading-relaxed',
isError ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground',
)}
>
{trimmedOutput}
</pre>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,77 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { QuestionAnswerContent } from './QuestionAnswerContent';
// Regression coverage for the chat-interface crash where an AskUserQuestion
// payload loaded from a session transcript arrives with a non-array `questions`
// or a question missing its `options` array. Rendering must degrade gracefully
// instead of throwing "TypeError: e.map is not a function".
test('renders without throwing when questions is a non-array value', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
// Malformed: object instead of an array
questions: { 0: { question: 'q?', options: [{ label: 'a' }] } } as never,
answers: {},
}),
);
});
});
test('renders without throwing when a question is missing options[]', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [{ question: 'Pick one?', header: 'H' } as never],
answers: { 'Pick one?': 'X' },
}),
);
});
});
test('renders without throwing when options[] contains malformed entries', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [{ question: 'Pick one?', options: [null, 'oops', { label: 'A' }] } as never],
answers: { 'Pick one?': 'A, Custom' },
}),
);
});
});
test('renders without throwing when a questions entry is null/non-object', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [null, 'oops', { question: 'Ok?', options: [{ label: 'A' }] }] as never,
answers: {},
}),
);
});
});
test('renders without throwing when an answer is a non-string value', () => {
assert.doesNotThrow(() => {
renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
// Malformed: answer is an object instead of the expected string
answers: { 'Pick one?': { unexpected: true } } as never,
}),
);
});
});
test('still renders a well-formed question + answer', () => {
const html = renderToStaticMarkup(
React.createElement(QuestionAnswerContent, {
questions: [{ question: 'Pick one?', header: 'H', options: [{ label: 'A' }, { label: 'B' }] }],
answers: { 'Pick one?': 'A' },
}),
);
assert.ok(html.includes('Pick one?'));
});

View File

@@ -15,7 +15,11 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
}) => {
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
if (!questions || questions.length === 0) {
// Tool inputs are runtime data loaded from session transcripts and may be
// malformed (e.g. `questions` arriving as a non-array). Guard with
// Array.isArray so a single bad payload can't crash the whole chat view
// with "e.map is not a function".
if (!Array.isArray(questions) || questions.length === 0) {
return null;
}
@@ -24,11 +28,23 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
return (
<div className={`space-y-2 ${className}`}>
{questions.map((q, idx) => {
{questions.map((rawQuestion, idx) => {
// Entries come from session transcripts and may be malformed; skip
// anything that isn't a proper question object with a string prompt.
if (!rawQuestion || typeof rawQuestion !== 'object' || typeof rawQuestion.question !== 'string') {
return null;
}
const q = rawQuestion;
const answer = answers?.[q.question];
const answerLabels = answer ? answer.split(', ') : [];
// `answer` may be a non-string (or absent) in malformed payloads.
const answerLabels = typeof answer === 'string' ? answer.split(', ') : [];
const skipped = !answer;
const isExpanded = expandedIdx === idx;
// `options` is typed as an array but comes from untrusted runtime data;
// keep only valid entries so `.some`/`.map` below never throw.
const options = Array.isArray(q.options)
? q.options.filter((opt) => opt && typeof opt === 'object' && typeof opt.label === 'string')
: [];
return (
<div
@@ -74,7 +90,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
{!isExpanded && answerLabels.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{answerLabels.map((lbl) => {
const isCustom = !q.options.some(o => o.label === lbl);
const isCustom = !options.some(o => o.label === lbl);
return (
<span
key={lbl}
@@ -110,7 +126,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
{isExpanded && (
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
<div className="ml-6.5 space-y-1">
{q.options.map((opt) => {
{options.map((opt) => {
const wasSelected = answerLabels.includes(opt.label);
return (
<div
@@ -148,7 +164,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
);
})}
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
{answerLabels.filter(lbl => !options.some(o => o.label === lbl)).map(lbl => (
<div
key={lbl}
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"

View File

@@ -1,6 +1,7 @@
export { CollapsibleSection } from './CollapsibleSection';
export { ToolDiffViewer } from './ToolDiffViewer';
export { OneLineDisplay } from './OneLineDisplay';
export { BashCommandDisplay } from './BashCommandDisplay';
export { CollapsibleDisplay } from './CollapsibleDisplay';
export { SubagentContainer } from './SubagentContainer';
export * from './ContentRenderers';

View File

@@ -0,0 +1,62 @@
import type { ChatMessage } from '../types/types';
export const TOOL_GROUP_THRESHOLD = 3;
export interface ToolGroupItem {
_isGroup: true;
toolName: string;
messages: ChatMessage[];
timestamp: ChatMessage['timestamp'];
}
export type MessageListItem = ChatMessage | ToolGroupItem;
export function isToolGroupItem(item: MessageListItem): item is ToolGroupItem {
return '_isGroup' in item && (item as ToolGroupItem)._isGroup === true;
}
function isGroupableToolMessage(message: ChatMessage): message is ChatMessage & { toolName: string } {
return Boolean(message.isToolUse && message.toolName && !message.isSubagentContainer);
}
export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[] {
const items: MessageListItem[] = [];
let index = 0;
while (index < messages.length) {
const message = messages[index];
if (!isGroupableToolMessage(message)) {
items.push(message);
index += 1;
continue;
}
const run: ChatMessage[] = [message];
let nextIndex = index + 1;
while (
nextIndex < messages.length &&
isGroupableToolMessage(messages[nextIndex]) &&
messages[nextIndex].toolName === message.toolName
) {
run.push(messages[nextIndex]);
nextIndex += 1;
}
if (run.length >= TOOL_GROUP_THRESHOLD) {
items.push({
_isGroup: true,
toolName: message.toolName,
messages: run,
timestamp: message.timestamp,
});
} else {
items.push(...run);
}
index = nextIndex;
}
return items;
}

View File

@@ -311,7 +311,7 @@ function ChatInterface({
return (
<PermissionContext.Provider value={permissionContextValue}>
<div className="flex h-full flex-col">
<div className="flex h-full min-h-0 flex-col">
<ChatMessagesPane
scrollContainerRef={scrollContainerRef}
onWheel={handleScroll}

View File

@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
ChangeEvent,
@@ -160,6 +161,17 @@ export default function ChatComposer({
sendByCtrlEnter,
}: ChatComposerProps) {
const { t } = useTranslation('chat');
const commandMenuPosition = useMemo(() => {
if (!isCommandMenuOpen) {
return { top: 0, left: 16, bottom: 90 };
}
const textareaRect = textareaRef.current?.getBoundingClientRect();
return {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
};
}, [input, isCommandMenuOpen, textareaRef]);
// Voice state is hosted here (not in the mic button) so the main Send button can stop
// recording and send the transcript in one tap, the way the mic button drops it in the box.
@@ -182,13 +194,6 @@ export default function ChatComposer({
const isRecording = voiceState === 'recording';
const isTranscribing = voiceState === 'transcribing';
const textareaRect = textareaRef.current?.getBoundingClientRect();
const commandMenuPosition = {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
};
// Detect if the AskUserQuestion interactive panel is active
const hasQuestionPanel = pendingPermissionRequests.some(
(r) => r.toolName === 'AskUserQuestion'
@@ -198,7 +203,7 @@ export default function ChatComposer({
const hasPendingPermissions = pendingPermissionRequests.length > 0;
return (
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
<div className="chat-composer-shell flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
{!hasPendingPermissions && (
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
)}

View File

@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useCallback, useRef } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react';
import type { ChatMessage } from '../../types/types';
@@ -10,9 +10,11 @@ import type {
ProviderModelsDefinition,
} from '../../../../types/app';
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
import { groupConsecutiveTools, isToolGroupItem } from '../../utils/toolGrouping';
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import ToolGroupContainer from './ToolGroupContainer';
interface ChatMessagesPaneProps {
scrollContainerRef: RefObject<HTMLDivElement>;
@@ -65,7 +67,7 @@ interface ChatMessagesPaneProps {
selectedProject: Project;
}
export default function ChatMessagesPane({
function ChatMessagesPane({
scrollContainerRef,
onWheel,
onTouchMove,
@@ -118,6 +120,7 @@ export default function ChatMessagesPane({
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
const allocatedKeysRef = useRef<Set<string>>(new Set());
const generatedMessageKeyCounterRef = useRef(0);
const groupedVisibleMessages = useMemo(() => groupConsecutiveTools(visibleMessages), [visibleMessages]);
// Keep keys stable across prepends so existing MessageComponent instances retain local state.
const getMessageKey = useCallback((message: ChatMessage) => {
@@ -148,7 +151,7 @@ export default function ChatMessagesPane({
ref={scrollContainerRef}
onWheel={onWheel}
onTouchMove={onTouchMove}
className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
className="chat-messages-pane relative min-h-0 flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
>
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
@@ -252,28 +255,58 @@ export default function ChatMessagesPane({
</div>
)}
{visibleMessages.map((message, index) => {
const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
return (
<MessageComponent
key={getMessageKey(message)}
message={message}
prevMessage={prevMessage}
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
provider={provider}
/>
);
})}
{(() => {
let prevMessage: ChatMessage | null = null;
return groupedVisibleMessages.map((item) => {
if (isToolGroupItem(item)) {
const groupPrevMessage = prevMessage;
prevMessage = item.messages[item.messages.length - 1] || prevMessage;
return (
<ToolGroupContainer
key={`tool-group-${getMessageKey(item.messages[0])}`}
group={item}
prevMessage={groupPrevMessage}
createDiff={createDiff}
getMessageKey={getMessageKey}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
provider={provider}
/>
);
}
const messagePrevMessage = prevMessage;
prevMessage = item;
return (
<MessageComponent
key={getMessageKey(item)}
message={item}
prevMessage={messagePrevMessage}
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
provider={provider}
/>
);
});
})()}
</>
)}
</div>
);
}
export default memo(ChatMessagesPane);

View File

@@ -2,9 +2,7 @@ import { useMemo, useState } from 'react';
import {
Activity,
BadgeCheck,
Check,
CircleHelp,
Clipboard,
Coins,
Cpu,
Gauge,
@@ -59,19 +57,6 @@ type ModelOption = {
description?: string;
};
const formatUpdatedAt = (value?: string) => {
if (!value) {
return 'Not cached yet';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return 'Not cached yet';
}
return parsed.toLocaleString();
};
const PROVIDER_LABELS: Record<string, string> = {
claude: 'Claude',
cursor: 'Cursor',
@@ -246,7 +231,6 @@ function HelpContent({ data }: { data: HelpCommandData }) {
function ModelsContent({
data,
providerModelCatalog,
providerModelCacheCatalog,
providerModelsRefreshing,
onHardRefreshProviderModels,
currentSessionId,
@@ -254,14 +238,12 @@ function ModelsContent({
}: {
data: ModelCommandData;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
providerModelsRefreshing: boolean;
onHardRefreshProviderModels: () => void;
currentSessionId: string | null;
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
}) {
const [query, setQuery] = useState('');
const [copiedModel, setCopiedModel] = useState<string | null>(null);
const [changingModel, setChangingModel] = useState<string | null>(null);
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
@@ -269,7 +251,6 @@ function ModelsContent({
const currentModel = data?.current?.model || 'Unknown';
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
const liveDefinition = providerModelCatalog[currentProvider];
const currentCache = providerModelCacheCatalog[currentProvider] ?? data?.cache;
const availableOptions = useMemo<ModelOption[]>(() => {
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
return liveDefinition.OPTIONS;
@@ -282,7 +263,6 @@ function ModelsContent({
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
return availableModels.map((model) => ({ value: model, label: model }));
}, [data, liveDefinition]);
const defaultModel = liveDefinition?.DEFAULT || data?.defaultModel || currentModel;
const filteredOptions = useMemo(() => {
const normalized = query.trim().toLowerCase();
@@ -296,18 +276,8 @@ function ModelsContent({
});
}, [availableOptions, query]);
const activeOption = availableOptions.find((option) => option.value === currentModel);
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
const copyModel = (model: string) => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(model).catch(() => undefined);
}
setCopiedModel(model);
window.setTimeout(() => {
setCopiedModel((current) => (current === model ? null : current));
}, 1300);
};
const showSearch = availableOptions.length > 6;
const handleSelectModel = async (model: string) => {
setChangingModel(model);
@@ -330,162 +300,106 @@ function ModelsContent({
};
return (
<div className="flex h-full min-h-0 flex-col gap-2.5">
<div className="rounded-2xl border border-border/70 bg-muted/20 p-2.5">
<div className="grid gap-2.5 lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.7fr)_minmax(15rem,0.9fr)] lg:items-start">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="rounded-lg border border-primary/20 bg-primary/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
{providerLabel}
</Badge>
<Badge variant="secondary" className="rounded-lg px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-foreground">
{availableOptions.length} models
</Badge>
</div>
<div className="mt-2 rounded-xl border border-primary/15 bg-primary/[0.06] px-3 py-2">
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary">Active Model</p>
<p className="mt-1 break-all font-mono text-[0.98rem] font-semibold leading-5 text-foreground sm:text-[1.05rem]">
{currentModel}
</p>
{activeOption?.label && activeOption.label !== currentModel && (
<p className="mt-1 text-[11px] font-medium text-foreground/85">{activeOption.label}</p>
)}
{activeOption?.description && (
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">{activeOption.description}</p>
)}
{pendingSessionModel && pendingSessionModel !== currentModel && (
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-primary">
Next response: {pendingSessionModel}
</p>
)}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Default</p>
<p className="mt-1 break-all font-mono text-[11px] font-medium text-foreground">{defaultModel}</p>
</div>
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Updated</p>
<p className="mt-1 text-[11px] font-medium text-foreground">{formatUpdatedAt(currentCache?.updatedAt)}</p>
</div>
</div>
<div className="rounded-xl border border-border/60 bg-background/55 p-2.5">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">
Catalog Refresh
</p>
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[9px] uppercase tracking-[0.14em]">
All providers
</Badge>
</div>
<p className="mt-1.5 text-[11px] leading-4 text-muted-foreground">
Model lists are cached for 3 days. Refresh after CLI, auth, or config changes,
or when a new model is missing.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={onHardRefreshProviderModels}
disabled={providerModelsRefreshing}
className="mt-2 h-8 w-full rounded-xl px-3"
>
<RefreshCw className={providerModelsRefreshing ? 'animate-spin' : ''} />
{providerModelsRefreshing ? 'Refreshing catalogs...' : 'Refresh from providers'}
</Button>
</div>
</div>
<div className="mt-2 border-t border-border/50 pt-1.5 text-[11px] text-muted-foreground">
{hasConcreteSessionId
? 'Selecting a model stores a session override and applies it on the next response for this session.'
: 'Selecting a model updates the default model used for new turns in this provider.'}
{selectionNotice && <span className="ml-2 text-foreground">{selectionNotice}</span>}
<div className="flex h-full min-h-0 flex-col gap-3">
{/* Compact context bar: active model + refresh, no clutter */}
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-muted/20 px-3.5 py-2.5">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Active model · {providerLabel}
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="break-all font-mono text-sm font-semibold text-foreground">{currentModel}</span>
{pendingSessionModel && pendingSessionModel !== currentModel && (
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-500 dark:text-emerald-400">
{pendingSessionModel} next
</span>
)}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onHardRefreshProviderModels}
disabled={providerModelsRefreshing}
title="Refresh model list from providers"
aria-label="Refresh model list from providers"
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:text-foreground"
>
<RefreshCw className={`h-4 w-4 ${providerModelsRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col rounded-3xl border border-border/70 bg-muted/15 p-3 sm:p-4">
<div className="mb-2.5 grid gap-2 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
</div>
<Badge variant="secondary" className="h-9 justify-center rounded-xl px-3 font-mono text-xs">
{filteredOptions.length} shown
</Badge>
</div>
{showSearch && (
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
)}
{filteredOptions.length > 0 ? (
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-2 md:grid-cols-2">
{filteredOptions.map((option, index) => {
const isCurrent = option.value === currentModel;
const wasCopied = copiedModel === option.value;
const isPendingSelection = option.value === pendingSessionModel;
const isChanging = option.value === changingModel;
return (
<div
key={option.value}
className={`settings-content-enter group flex min-h-[4.5rem] items-start gap-3 rounded-2xl border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${
isCurrent
? 'border-primary/45 bg-primary/10'
: isPendingSelection
? 'border-emerald-500/35 bg-emerald-500/10'
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
}`}
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
>
<button
type="button"
onClick={() => handleSelectModel(option.value)}
disabled={Boolean(changingModel)}
className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Use model ${option.value}`}
>
<span className="flex items-center gap-2">
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
{isCurrent && <BadgeCheck className="h-4 w-4 shrink-0 text-primary" />}
</span>
{option.label && option.label !== option.value && (
<span className="mt-1 block text-xs text-muted-foreground">{option.label}</span>
)}
{option.description && (
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>
)}
{isCurrent && <span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>}
{isPendingSelection && !isCurrent && (
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-400">
Next response selection
</span>
)}
{isChanging && (
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
Applying...
</span>
)}
</button>
<button
type="button"
onClick={() => copyModel(option.value)}
className="rounded-lg border border-border/70 bg-muted/30 p-2 text-muted-foreground transition-colors group-hover:text-primary"
aria-label={`Copy model id ${option.value}`}
>
{wasCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
</button>
</div>
);
})}
</div>
{filteredOptions.length > 0 ? (
<div className="scrollbar-thin -mr-1 min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-2 md:grid-cols-2">
{filteredOptions.map((option, index) => {
const isCurrent = option.value === currentModel;
const isPendingSelection = option.value === pendingSessionModel;
const isChanging = option.value === changingModel;
return (
<button
key={option.value}
type="button"
onClick={() => handleSelectModel(option.value)}
disabled={Boolean(changingModel)}
aria-label={`Select model ${option.value}`}
className={`settings-content-enter group flex min-h-[4rem] flex-col rounded-2xl border p-3 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60 ${
isCurrent
? 'border-primary/45 bg-primary/10'
: isPendingSelection
? 'border-emerald-500/35 bg-emerald-500/10'
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
}`}
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
>
<span className="flex items-center justify-between gap-2">
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
{isCurrent ? (
<BadgeCheck className="h-4 w-4 shrink-0 text-primary" />
) : isChanging ? (
<RefreshCw className="h-4 w-4 shrink-0 animate-spin text-primary" />
) : null}
</span>
{option.label && option.label !== option.value && (
<span className="mt-1 text-xs font-medium text-foreground/85">{option.label}</span>
)}
{option.description && (
<span className="mt-1 text-xs leading-5 text-muted-foreground">{option.description}</span>
)}
{isCurrent && (
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>
)}
{isPendingSelection && !isCurrent && (
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-500 dark:text-emerald-400">
Applies next response
</span>
)}
</button>
);
})}
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
No models match that search.
</div>
)}
{/* Single quiet line of guidance / feedback */}
<p className="shrink-0 text-[11px] leading-4 text-muted-foreground">
{selectionNotice ? (
<span className="text-foreground">{selectionNotice}</span>
) : hasConcreteSessionId ? (
'Your choice applies to this session on the next response.'
) : (
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
No models match that search.
</div>
'Your choice becomes the default model for new turns.'
)}
</div>
</p>
</div>
);
}
@@ -606,7 +520,6 @@ export default function CommandResultModal({
payload,
onClose,
providerModelCatalog,
providerModelCacheCatalog,
providerModelsRefreshing,
onHardRefreshProviderModels,
currentSessionId,
@@ -624,9 +537,9 @@ export default function CommandResultModal({
icon: CircleHelp,
},
models: {
eyebrow: 'Model inventory',
title: 'Available Models',
subtitle: 'Browse, search, and copy model IDs for the active provider.',
eyebrow: 'Model selection',
title: 'Choose a Model',
subtitle: 'Pick the model this provider should use.',
icon: Cpu,
},
cost: {
@@ -700,7 +613,6 @@ export default function CommandResultModal({
<ModelsContent
data={payload.data as ModelCommandData}
providerModelCatalog={providerModelCatalog}
providerModelCacheCatalog={providerModelCacheCatalog}
providerModelsRefreshing={providerModelsRefreshing}
onHardRefreshProviderModels={onHardRefreshProviderModels}
currentSessionId={currentSessionId}

View File

@@ -8,12 +8,48 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTranslation } from 'react-i18next';
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
type MarkdownProps = {
children: React.ReactNode;
className?: string;
};
// Links to the wider web (or in-page anchors) keep normal browser navigation;
// everything else is treated as a workspace file reference.
const isExternalHref = (href?: string): boolean =>
!!href && (/^(https?:|mailto:|tel:|data:)/i.test(href) || href.startsWith('#'));
// Strip a trailing `:line` / `:line:col` suffix (e.g. `src/foo.ts:130`).
const stripLineSuffix = (value: string): string => value.replace(/:\d+(?::\d+)?$/, '');
// A usable file path contains a separator or a filename with an extension.
const looksLikeFilePath = (value?: string): value is string => {
if (!value) {
return false;
}
const cleaned = stripLineSuffix(value.trim());
if (!cleaned || cleaned === '#') {
return false;
}
return /[\\/]/.test(cleaned) || /\.[a-z0-9]+$/i.test(cleaned);
};
// Extract plain text from link children so a reference rendered only as link
// text (e.g. `[src/foo.ts]()` with an empty href) can still be opened.
const childrenToText = (children: React.ReactNode): string => {
if (typeof children === 'string' || typeof children === 'number') {
return String(children);
}
if (Array.isArray(children)) {
return children.map(childrenToText).join('');
}
if (React.isValidElement(children)) {
return childrenToText((children.props as { children?: React.ReactNode }).children);
}
return '';
};
type CodeBlockProps = {
node?: any;
inline?: boolean;
@@ -123,11 +159,6 @@ const markdownComponents = {
{children}
</blockquote>
),
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a href={href} className="text-blue-600 hover:underline dark:text-blue-400" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
table: ({ children }: { children?: React.ReactNode }) => (
<div className="my-2 overflow-x-auto">
@@ -147,10 +178,50 @@ export function Markdown({ children, className }: MarkdownProps) {
const content = normalizeInlineCodeFences(String(children ?? ''));
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
const rehypePlugins = useMemo(() => [rehypeKatex], []);
const { openFileInEditor } = usePaletteOps();
const components = useMemo(
() => ({
...markdownComponents,
a: ({ href, children: linkChildren }: { href?: string; children?: React.ReactNode }) => {
// Prefer the href when it is a real path; otherwise fall back to the
// link text, since models often emit `[src/foo.ts]()` with an empty href.
const linkText = childrenToText(linkChildren);
const fileRef = looksLikeFilePath(href) ? href : looksLikeFilePath(linkText) ? linkText : undefined;
if (fileRef && !isExternalHref(href)) {
return (
<a
href={href || fileRef}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
onClick={(event) => {
event.preventDefault();
openFileInEditor(stripLineSuffix(fileRef));
}}
>
{linkChildren}
</a>
);
}
return (
<a
href={href}
className="text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noopener noreferrer"
>
{linkChildren}
</a>
);
},
}),
[openFileInEditor],
);
return (
<div className={className}>
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components as any}>
{content}
</ReactMarkdown>
</div>

View File

@@ -218,8 +218,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
/>
)}
{/* Tool Result Section */}
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
{/* Tool Result Section — Bash renders its output inside the command row above. */}
{message.toolResult && message.toolName !== 'Bash' && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
message.toolResult.isError ? (
// Error results - red error box with content
<div

View File

@@ -0,0 +1,147 @@
import { useMemo, useState } from 'react';
import { ChevronRight } from 'lucide-react';
import type { ChatMessage, ClaudePermissionSuggestion, PermissionGrantResult, Provider } from '../../types/types';
import type { Project } from '../../../../types/app';
import type { ToolGroupItem } from '../../utils/toolGrouping';
import { getToolConfig } from '../../tools';
import MessageComponent from './MessageComponent';
type DiffLine = {
type: string;
content: string;
lineNum: number;
};
interface ToolGroupContainerProps {
group: ToolGroupItem;
prevMessage: ChatMessage | null;
createDiff: (oldStr: string, newStr: string) => DiffLine[];
getMessageKey: (message: ChatMessage) => string;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject?: Project | null;
provider: Provider | string;
}
function parseToolInput(toolInput: unknown): unknown {
if (typeof toolInput !== 'string') {
return toolInput;
}
try {
return JSON.parse(toolInput);
} catch {
return toolInput;
}
}
function getToolInputPreview(message: ChatMessage): string {
const config = getToolConfig(message.toolName || 'UnknownTool').input;
const parsedInput = parseToolInput(message.toolInput);
const title = typeof config.title === 'function' ? config.title(parsedInput) : config.title;
const value = config.getValue?.(parsedInput);
return String(value || title || message.displayText || message.content || '').trim();
}
function getToolGroupIcon(icon: string | undefined, toolName: string): string {
if (icon === 'terminal') {
return '$';
}
return icon || toolName.slice(0, 1).toUpperCase();
}
export default function ToolGroupContainer({
group,
prevMessage,
createDiff,
getMessageKey,
onFileOpen,
onShowSettings,
onGrantToolPermission,
autoExpandTools,
showRawParameters,
showThinking,
selectedProject,
provider,
}: ToolGroupContainerProps) {
const [isExpanded, setIsExpanded] = useState(false);
const config = getToolConfig(group.toolName).input;
const label = config.label || group.toolName;
const borderClass = config.colorScheme?.border || 'border-border';
const iconClass = config.colorScheme?.icon || 'text-muted-foreground';
const icon = getToolGroupIcon(config.icon, group.toolName);
const preview = useMemo(() => {
const visiblePreviews = group.messages
.slice(0, 2)
.map(getToolInputPreview)
.filter(Boolean);
const extraCount = group.messages.length - visiblePreviews.length;
const previewText = visiblePreviews.join(', ');
if (!previewText) {
return extraCount > 0 ? `+${extraCount} more` : '';
}
return extraCount > 0 ? `${previewText}, +${extraCount} more` : previewText;
}, [group.messages]);
return (
<div className="chat-message tool px-3 sm:px-0" data-message-timestamp={group.timestamp || undefined}>
<button
type="button"
className={`group flex w-full items-center gap-2 border-l-2 ${borderClass} rounded-r-md bg-muted/25 px-3 py-2 text-left transition-colors hover:bg-muted/40 dark:bg-muted/10 dark:hover:bg-muted/20`}
onClick={() => setIsExpanded((current) => !current)}
aria-expanded={isExpanded}
>
<ChevronRight
className={`h-3.5 w-3.5 flex-shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-90' : ''}`}
aria-hidden
/>
<span className={`${iconClass} flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-background/80 text-xs font-medium`}>
{icon}
</span>
<span className="min-w-0 flex-shrink-0 text-xs font-medium text-foreground">{label}</span>
<span className="flex-shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
x{group.messages.length}
</span>
{preview && (
<>
<span className="text-[10px] text-muted-foreground/40">/</span>
<span className="min-w-0 truncate font-mono text-xs text-muted-foreground">{preview}</span>
</>
)}
</button>
{isExpanded && (
<div className="mt-2 space-y-3 sm:space-y-4">
{group.messages.map((message, index) => (
<MessageComponent
key={getMessageKey(message)}
message={message}
prevMessage={index > 0 ? group.messages[index - 1] : prevMessage}
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
provider={provider}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export { default as ComputerUsePanel } from './view/ComputerUsePanel';

View File

@@ -0,0 +1,537 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react';
import { Bot, Camera, Download, Expand, Loader2, MonitorCog, RefreshCw, Settings, ShieldCheck, Square, Trash2, X } from 'lucide-react';
import { cn } from '../../../lib/utils';
import { Badge, Button } from '../../../shared/view/ui';
import { authenticatedFetch } from '../../../utils/api';
import type { SettingsMainTab } from '../../settings/types/types';
type ComputerUseStatus = {
enabled: boolean;
runtime: 'cloud' | 'local';
available: boolean;
desktopAgentConnected?: boolean;
desktopAgentCount?: number;
nutInstalled: boolean;
screenshotInstalled: boolean;
installInProgress: boolean;
sessionCount: number;
message: string;
};
type ComputerUseSession = {
id: string;
status: 'ready' | 'stopped' | 'unavailable';
screenshotDataUrl: string | null;
createdAt: string;
updatedAt: string;
lastAction: string | null;
message: string | null;
agentAccessEnabled: boolean;
createdBy: 'user' | 'agent';
displaySize: {
width: number;
height: number;
} | null;
cursor: {
x: number;
y: number;
actor: 'agent' | 'user';
} | null;
};
type ComputerUsePanelProps = {
isVisible: boolean;
onShowSettings?: (tab?: SettingsMainTab) => void;
};
async function readJson<T>(response: Response): Promise<T> {
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || data.details || `Request failed (${response.status})`);
}
return data as T;
}
function getRuntimeTone(status: ComputerUseStatus | null, installing: boolean): string {
if (!status?.enabled) return 'border-border bg-muted text-muted-foreground';
if (status.runtime === 'cloud') {
return status.desktopAgentConnected
? 'border-primary/30 bg-primary/5 text-foreground'
: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300';
}
if (status.available) return 'border-primary/30 bg-primary/5 text-foreground';
if (status.installInProgress || installing) return 'border-primary/30 bg-primary/5 text-foreground';
return 'border-border bg-background text-muted-foreground';
}
function getRuntimeLabel(status: ComputerUseStatus | null, installing: boolean): string {
if (!status?.enabled) return 'Disabled';
if (status.runtime === 'cloud') {
const count = status.desktopAgentCount ?? (status.desktopAgentConnected ? 1 : 0);
if (count > 1) return `${count} desktops linked`;
if (count === 1) return 'Desktop linked';
return 'Desktop not linked';
}
if (status.available) return 'Ready';
if (status.installInProgress || installing) return 'Installing';
return 'Setup required';
}
export default function ComputerUsePanel({ isVisible, onShowSettings }: ComputerUsePanelProps) {
const [status, setStatus] = useState<ComputerUseStatus | null>(null);
const [sessions, setSessions] = useState<ComputerUseSession[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isBusy, setIsBusy] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [error, setError] = useState<string | null>(null);
const viewerRef = useRef<HTMLDivElement | null>(null);
const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
[selectedSessionId, sessions],
);
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
const [statusResponse, sessionsResponse] = await Promise.all([
authenticatedFetch('/api/computer-use/status'),
authenticatedFetch('/api/computer-use/sessions'),
]);
const statusData = await readJson<{ data: ComputerUseStatus }>(statusResponse);
const sessionsData = await readJson<{ data: { sessions: ComputerUseSession[] } }>(sessionsResponse);
setStatus(statusData.data);
setSessions(sessionsData.data.sessions);
setSelectedSessionId((current) => (
current && sessionsData.data.sessions.some((session) => session.id === current)
? current
: sessionsData.data.sessions[0]?.id || null
));
setError(null);
} finally {
setIsRefreshing(false);
}
}, []);
useEffect(() => {
if (!isVisible) return;
void refresh().catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Computer Use'));
}, [isVisible, refresh]);
const handleRefresh = useCallback(() => {
void refresh().catch((err) => setError(err instanceof Error ? err.message : 'Failed to refresh Computer Use'));
}, [refresh]);
// Poll while an active session exists so agent-driven changes show up live.
useEffect(() => {
if (!isVisible || !selectedSession || selectedSession.status !== 'ready') return;
const timer = window.setInterval(() => {
void refresh().catch(() => undefined);
}, 1500);
return () => window.clearInterval(timer);
}, [isVisible, selectedSession, refresh]);
const runAction = useCallback(async (action: () => Promise<void>) => {
setIsBusy(true);
setError(null);
try {
await action();
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Computer Use action failed');
} finally {
setIsBusy(false);
}
}, [refresh]);
const captureScreenshot = () => runAction(async () => {
if (!selectedSession) return;
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/screenshot`, { method: 'POST' });
await readJson(response);
});
const stopSession = () => runAction(async () => {
if (!selectedSession) return;
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/stop`, { method: 'POST' });
await readJson(response);
});
const deleteSession = () => runAction(async () => {
if (!selectedSession) return;
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}`, { method: 'DELETE' });
await readJson(response);
setIsFullscreen(false);
});
const grantControl = () => runAction(async () => {
if (!selectedSession) return;
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/consent/grant`, { method: 'POST' });
await readJson(response);
});
const revokeControl = () => runAction(async () => {
if (!selectedSession) return;
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/consent/revoke`, { method: 'POST' });
await readJson(response);
});
const installRuntime = () => runAction(async () => {
setIsInstalling(true);
try {
const response = await authenticatedFetch('/api/computer-use/runtime/install', { method: 'POST' });
await readJson(response);
} finally {
setIsInstalling(false);
}
});
const clickViewer = useCallback((event: MouseEvent<HTMLImageElement>) => {
if (!selectedSession || selectedSession.status !== 'ready' || !selectedSession.displaySize) {
return;
}
viewerRef.current?.focus();
const bounds = event.currentTarget.getBoundingClientRect();
const scaleX = selectedSession.displaySize.width / bounds.width;
const scaleY = selectedSession.displaySize.height / bounds.height;
const x = Math.round((event.clientX - bounds.left) * scaleX);
const y = Math.round((event.clientY - bounds.top) * scaleY);
void runAction(async () => {
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/click`, {
method: 'POST',
body: JSON.stringify({ x, y, double: event.detail === 2 }),
});
await readJson(response);
});
}, [runAction, selectedSession]);
const keyForEvent = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === ' ') return 'Space';
const parts: string[] = [];
if (event.ctrlKey) parts.push('ctrl');
if (event.altKey) parts.push('alt');
if (event.shiftKey && event.key.length > 1) parts.push('shift');
if (event.metaKey) parts.push('meta');
parts.push(event.key);
return parts.join('+');
}, []);
const pressViewerKey = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (!selectedSession || selectedSession.status !== 'ready') {
return;
}
const ignoredKeys = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock']);
if (ignoredKeys.has(event.key)) {
return;
}
event.preventDefault();
const key = keyForEvent(event);
void runAction(async () => {
const response = await authenticatedFetch(`/api/computer-use/sessions/${selectedSession.id}/press-key`, {
method: 'POST',
body: JSON.stringify({ key }),
});
await readJson(response);
});
}, [keyForEvent, runAction, selectedSession]);
const needsRuntime = Boolean(status?.enabled && status.runtime === 'local' && (!status.nutInstalled || !status.screenshotInstalled));
const isCloud = status?.runtime === 'cloud';
const desktopAgentCount = status?.desktopAgentCount ?? (status?.desktopAgentConnected ? 1 : 0);
const runtimeLabel = getRuntimeLabel(status, isInstalling);
const cursorStyle = selectedSession?.cursor && selectedSession.displaySize
? {
left: `${(selectedSession.cursor.x / selectedSession.displaySize.width) * 100}%`,
top: `${(selectedSession.cursor.y / selectedSession.displaySize.height) * 100}%`,
}
: null;
const renderSurface = (fullscreen = false) => (
<div
ref={viewerRef}
tabIndex={selectedSession?.status === 'ready' ? 0 : -1}
onKeyDown={pressViewerKey}
className={`flex min-h-[360px] flex-1 items-center justify-center bg-neutral-950 outline-none ${fullscreen ? 'min-h-[80vh]' : ''}`}
>
{selectedSession?.screenshotDataUrl ? (
<div className="relative inline-block max-h-full">
<img
src={selectedSession.screenshotDataUrl}
alt="Desktop screenshot"
className={fullscreen ? 'block max-h-[80vh] w-auto max-w-full object-contain' : 'block max-h-[70vh] w-auto max-w-full object-contain'}
onClick={clickViewer}
/>
{cursorStyle && (
<div
className="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white/90 bg-sky-500/80 shadow-[0_0_0_6px_rgba(14,165,233,0.18)]"
style={cursorStyle}
>
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
</div>
)}
</div>
) : (
<div className="max-w-md px-6 text-center">
<MonitorCog className="mx-auto h-10 w-10 text-neutral-500" />
<div className="mt-3 text-sm font-medium text-neutral-100">
{selectedSession?.message || 'No active Computer Use session.'}
</div>
<p className="mt-2 text-xs leading-relaxed text-neutral-400">
{isCloud
? 'Agents create sessions automatically. Keep the CloudCLI desktop app connected to approve control requests.'
: 'Agents create sessions automatically. Enable Computer Use and install the local runtime if needed.'}
</p>
</div>
)}
</div>
);
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<MonitorCog className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold text-foreground">Computer Use</h3>
<Badge variant="outline" className={cn('text-[10px]', getRuntimeTone(status, isInstalling))}>
{runtimeLabel}
</Badge>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{isCloud
? 'Monitor cloud agent desktop sessions and linked desktops.'
: 'Monitor local desktop sessions and grant control only when an agent needs it.'}
</p>
</div>
<div className="flex items-center gap-1.5">
{onShowSettings && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => onShowSettings('computer')}
title="Open Computer Use settings"
aria-label="Open Computer Use settings"
>
<Settings className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={handleRefresh}
disabled={isRefreshing || isBusy}
title="Refresh Computer Use"
aria-label="Refresh Computer Use"
>
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[300px_minmax(0,1fr)]">
<aside className="border-b border-border/60 p-3 lg:border-b-0 lg:border-r">
{isCloud && (
<div className="rounded-lg border border-border/70 bg-card/40 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Cloud desktop access</div>
<div className="mt-1 text-sm font-medium text-foreground">{runtimeLabel}</div>
</div>
<Badge variant="outline" className={cn('shrink-0 text-[10px]', getRuntimeTone(status, isInstalling))}>
{desktopAgentCount > 0 ? `${desktopAgentCount} linked` : 'Not linked'}
</Badge>
</div>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
{desktopAgentCount > 1
? 'More than one CloudCLI Desktop app is linked. Agents will use one available desktop.'
: desktopAgentCount === 1
? 'CloudCLI Desktop is connected. Approval prompts appear on that computer.'
: 'Open CloudCLI Desktop on the computer you want agents to use, connect the same account, and enable Computer Use.'}
</p>
</div>
)}
{needsRuntime && (
<div className={cn('rounded-lg border border-border/70 bg-card/40 p-3', isCloud && 'mt-3')}>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Desktop runtime required</div>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
{status?.message || 'Install the desktop control runtime to enable Computer Use.'}
</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-md border border-border px-2 py-1">
Control lib: {status?.nutInstalled ? 'installed' : 'missing'}
</span>
<span className="rounded-md border border-border px-2 py-1">
Screen capture: {status?.screenshotInstalled ? 'installed' : 'missing'}
</span>
</div>
<Button
type="button"
size="sm"
className="mt-3 w-full"
onClick={installRuntime}
disabled={isBusy || isInstalling || status?.installInProgress}
>
{isInstalling || status?.installInProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isInstalling || status?.installInProgress ? 'Installing…' : 'Install Runtime'}
</Button>
</div>
)}
<div className="mt-3 space-y-2">
<div className="rounded-lg border border-border/70 bg-muted/30 p-3 text-xs leading-relaxed text-muted-foreground">
<div className="flex items-center gap-1.5 font-medium text-foreground">
<ShieldCheck className="h-3.5 w-3.5" />
Safety
</div>
{isCloud ? (
<p className="mt-1.5">
Agents create sessions automatically through MCP. The CloudCLI desktop app asks for approval on this
computer, and <span className="font-medium text-foreground">Stop</span> ends the session and clears access.
</p>
) : (
<p className="mt-1.5">
Agents create sessions automatically through MCP but cannot act until you grant control here. Use
<span className="font-medium text-foreground"> Grant Control </span>
to allow agent actions, and
<span className="font-medium text-foreground"> Stop </span>
to revoke instantly.
</p>
)}
</div>
{sessions.map((session) => (
<button
key={session.id}
type="button"
onClick={() => setSelectedSessionId(session.id)}
className={`w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors ${selectedSession?.id === session.id
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-border/60 bg-card/30 text-muted-foreground hover:bg-muted/50'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-medium">
{session.createdBy === 'agent' ? 'Agent session' : 'Desktop session'}
</span>
<Badge variant="outline" className="text-[10px]">{session.status}</Badge>
</div>
<div className="mt-1 flex flex-wrap gap-1">
{session.agentAccessEnabled ? (
<span className="rounded border border-emerald-500/30 px-1.5 py-0.5 text-[10px] text-emerald-600 dark:text-emerald-300">
control granted
</span>
) : (
<span className="rounded border border-amber-500/30 px-1.5 py-0.5 text-[10px] text-amber-600 dark:text-amber-300">
awaiting consent
</span>
)}
</div>
<div className="mt-1 truncate text-xs">{session.lastAction || session.message || session.id}</div>
</button>
))}
{sessions.length === 0 && (
<div className="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
Agents will create sessions automatically when they need desktop access.
</div>
)}
</div>
</aside>
<main className="flex min-h-0 flex-col">
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
<Button variant="outline" size="sm" onClick={captureScreenshot} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
<Camera className="h-4 w-4" />
Screenshot
</Button>
{!isCloud && selectedSession?.agentAccessEnabled ? (
<Button variant="outline" size="sm" onClick={revokeControl} disabled={isBusy || !selectedSession}>
<X className="h-4 w-4" />
Revoke Control
</Button>
) : !isCloud ? (
<Button
variant="outline"
size="sm"
onClick={grantControl}
disabled={isBusy || !selectedSession || selectedSession.status !== 'ready' || !status?.enabled}
>
<Bot className="h-4 w-4" />
Grant Control
</Button>
) : null}
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl}>
<Expand className="h-4 w-4" />
Full Screen
</Button>
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
<Square className="h-4 w-4" />
Stop
</Button>
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
{error && (
<div className="border-b border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
{error}
</div>
)}
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
<div className="mx-auto flex min-h-[420px] max-w-6xl flex-col overflow-hidden rounded-lg border border-border bg-background shadow-sm">
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2 text-xs text-muted-foreground">
<MonitorCog className="h-3.5 w-3.5" />
<span className="truncate">
{selectedSession?.displaySize
? `${selectedSession.displaySize.width}×${selectedSession.displaySize.height}`
: 'No screen captured'}
</span>
{selectedSession?.agentAccessEnabled && (
<span className="ml-auto inline-flex items-center gap-1 rounded border border-emerald-500/30 px-2 py-0.5 text-emerald-600 dark:text-emerald-300">
<Bot className="h-3.5 w-3.5" />
{isCloud ? 'Desktop-approved session' : 'Agent control active'}
</span>
)}
</div>
{renderSurface()}
</div>
<p className="mx-auto mt-2 max-w-6xl text-center text-xs text-muted-foreground">
{selectedSession
? 'Click the screenshot to click the real desktop. Focus the view and type to send keystrokes.'
: 'Computer Use sessions appear here after an agent requests desktop access.'}
</p>
</div>
</main>
</div>
{isFullscreen && selectedSession && (
<div className="fixed inset-0 z-50 bg-black/90 p-6">
<div className="flex h-full flex-col rounded-lg border border-white/10 bg-black">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 text-sm text-white/80">
<div className="min-w-0 truncate">Desktop session</div>
<Button variant="outline" size="sm" onClick={() => setIsFullscreen(false)}>
<X className="h-4 w-4" />
Close
</Button>
</div>
{renderSurface(true)}
</div>
</div>
)}
</div>
);
}

View File

@@ -66,6 +66,7 @@ export type MainContentHeaderProps = {
selectedSession: ProjectSession | null;
shouldShowTasksTab: boolean;
shouldShowBrowserTab: boolean;
shouldShowComputerTab: boolean;
isMobile: boolean;
onMenuClick: () => void;
};

View File

@@ -6,11 +6,14 @@ import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
import GitPanel from '../../git-panel/view/GitPanel';
import PluginTabContent from '../../plugins/view/PluginTabContent';
import { BrowserUsePanel } from '../../browser-use';
import { ComputerUsePanel } from '../../computer-use';
import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useUiPreferences } from '../../../hooks/useUiPreferences';
import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver';
import { COMPUTER_USE_MENUS_ENABLED } from '../../../constants/featureFlags';
import { authenticatedFetch } from '../../../utils/api';
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
import EditorSidebar from '../../code-editor/view/EditorSidebar';
@@ -58,9 +61,11 @@ function MainContent({
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
const [browserUseEnabled, setBrowserUseEnabled] = useState(false);
const [computerUseEnabled, setComputerUseEnabled] = useState<boolean | undefined>(undefined);
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const shouldShowBrowserTab = browserUseEnabled;
const shouldShowComputerTab = COMPUTER_USE_MENUS_ENABLED && computerUseEnabled === true;
const {
editingFile,
@@ -77,6 +82,10 @@ function MainContent({
isMobile,
});
// Resolves bare/partial file references (e.g. links inside chat messages) to
// real project files before opening them in the in-app editor.
const resolvedFileOpen = useFileOpenResolver(selectedProject, handleFileOpen);
useEffect(() => {
// Identify projects by DB `projectId`; the TaskMaster context uses the
// same identifier to key its internal maps.
@@ -116,11 +125,69 @@ function MainContent({
}
}, [shouldShowBrowserTab, activeTab, setActiveTab]);
const loadComputerUseSettings = useCallback(async () => {
try {
const [settingsResponse, statusResponse] = await Promise.allSettled([
authenticatedFetch('/api/computer-use/settings'),
authenticatedFetch('/api/computer-use/status'),
]);
const settingsRes = settingsResponse.status === 'fulfilled' ? settingsResponse.value : null;
const statusRes = statusResponse.status === 'fulfilled' ? statusResponse.value : null;
const readJson = async (response: Response | null) => {
if (!response) return null;
try {
return await response.json();
} catch {
return null;
}
};
const settingsData = await readJson(settingsRes);
const statusData = await readJson(statusRes);
const runtime = statusData?.data?.runtime;
const settingsUsable = Boolean(settingsRes?.ok && settingsData?.success !== false);
const statusUsable = Boolean(statusRes?.ok && statusData?.success !== false);
const settingsEnabled = Boolean(
settingsUsable &&
settingsData?.data?.settings?.enabled
);
const cloudEnabled = Boolean(
statusUsable &&
runtime === 'cloud' &&
statusData?.data?.enabled
);
if (runtime === 'cloud') {
setComputerUseEnabled(cloudEnabled);
} else if (settingsUsable) {
setComputerUseEnabled(settingsEnabled);
} else if (statusUsable) {
setComputerUseEnabled(Boolean(statusData?.data?.enabled));
}
} catch {
// Keep the current tab availability on transient status/settings failures.
}
}, []);
useEffect(() => {
void loadComputerUseSettings();
window.addEventListener('computerUseSettingsChanged', loadComputerUseSettings);
return () => window.removeEventListener('computerUseSettingsChanged', loadComputerUseSettings);
}, [loadComputerUseSettings]);
useEffect(() => {
if (!shouldShowComputerTab && activeTab === 'computer') {
setActiveTab('chat');
}
}, [shouldShowComputerTab, activeTab, setActiveTab]);
usePaletteOpsRegister({
openFile: (filePath: string) => {
setActiveTab('files');
handleFileOpen(filePath);
},
// Opens the editor side panel in place, keeping the current tab (e.g. chat).
openFileInEditor: (filePath: string) => {
resolvedFileOpen(filePath);
},
});
if (isLoading) {
@@ -140,6 +207,7 @@ function MainContent({
selectedSession={selectedSession}
shouldShowTasksTab={shouldShowTasksTab}
shouldShowBrowserTab={shouldShowBrowserTab}
shouldShowComputerTab={shouldShowComputerTab}
isMobile={isMobile}
onMenuClick={onMenuClick}
/>
@@ -208,6 +276,12 @@ function MainContent({
</div>
)}
{shouldShowComputerTab && activeTab === 'computer' && (
<div className="h-full overflow-hidden">
<ComputerUsePanel isVisible={activeTab === 'computer'} onShowSettings={onShowSettings} />
</div>
)}
{activeTab.startsWith('plugin:') && (
<div className="h-full overflow-hidden">
<PluginTabContent

View File

@@ -11,6 +11,7 @@ export default function MainContentHeader({
selectedSession,
shouldShowTasksTab,
shouldShowBrowserTab,
shouldShowComputerTab,
isMobile,
onMenuClick,
}: MainContentHeaderProps) {
@@ -61,6 +62,7 @@ export default function MainContentHeader({
setActiveTab={setActiveTab}
shouldShowTasksTab={shouldShowTasksTab}
shouldShowBrowserTab={shouldShowBrowserTab}
shouldShowComputerTab={shouldShowComputerTab}
/>
</div>
{canScrollRight && (

View File

@@ -1,4 +1,4 @@
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, MonitorPlay, type LucideIcon } from 'lucide-react';
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, MonitorCog, MonitorPlay, type LucideIcon } from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
@@ -12,6 +12,7 @@ type MainContentTabSwitcherProps = {
setActiveTab: Dispatch<SetStateAction<AppTab>>;
shouldShowTasksTab: boolean;
shouldShowBrowserTab: boolean;
shouldShowComputerTab: boolean;
};
type BuiltInTab = {
@@ -45,6 +46,13 @@ const BROWSER_TAB: BuiltInTab = {
icon: MonitorPlay,
};
const COMPUTER_TAB: BuiltInTab = {
kind: 'builtin',
id: 'computer',
labelKey: 'tabs.computer',
icon: MonitorCog,
};
const TASKS_TAB: BuiltInTab = {
kind: 'builtin',
id: 'tasks',
@@ -57,6 +65,7 @@ export default function MainContentTabSwitcher({
setActiveTab,
shouldShowTasksTab,
shouldShowBrowserTab,
shouldShowComputerTab,
}: MainContentTabSwitcherProps) {
const { t } = useTranslation();
const { plugins } = usePlugins();
@@ -64,6 +73,7 @@ export default function MainContentTabSwitcher({
const builtInTabs: BuiltInTab[] = [
...BASE_TABS,
...(shouldShowBrowserTab ? [BROWSER_TAB] : []),
...(shouldShowComputerTab ? [COMPUTER_TAB] : []),
...(shouldShowTasksTab ? [TASKS_TAB] : []),
];

View File

@@ -29,7 +29,11 @@ function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: st
}
if (activeTab === 'browser') {
return 'Browser';
return t('tabs.browser');
}
if (activeTab === 'computer') {
return t('tabs.computer');
}
return 'Project';

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTheme } from '../../../contexts/ThemeContext';
import { COMPUTER_USE_MENUS_ENABLED } from '../../../constants/featureFlags';
import { authenticatedFetch } from '../../../utils/api';
import { setNotificationSoundEnabled } from '../../../utils/notificationSound';
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
@@ -54,11 +55,11 @@ type NotificationPreferencesResponse = {
type ActiveLoginProvider = AgentProvider | '';
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'browser', 'notifications', 'plugins', 'about'];
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'browser', 'computer', 'notifications', 'plugins', 'about'];
const normalizeMainTab = (tab: string): SettingsMainTab => {
// Keep backwards compatibility with older callers that still pass "tools".
if (tab === 'tools') {
if (tab === 'tools' || (tab === 'computer' && !COMPUTER_USE_MENUS_ENABLED)) {
return 'agents';
}
@@ -109,6 +110,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
channels: {
inApp: true,
webPush: false,
desktop: false,
sound: true,
},
events: {
@@ -127,6 +129,7 @@ const normalizeNotificationPreferences = (
channels: {
inApp: preferences?.channels?.inApp ?? defaults.channels.inApp,
webPush: preferences?.channels?.webPush ?? defaults.channels.webPush,
desktop: preferences?.channels?.desktop ?? defaults.channels.desktop,
sound: preferences?.channels?.sound ?? defaults.channels.sound,
},
events: {

View File

@@ -3,7 +3,7 @@ import type { Dispatch, SetStateAction } from 'react';
import type { LLMProvider } from '../../../types/app';
import type { ProviderAuthStatus } from '../../provider-auth/types';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'voice' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'voice' | 'tasks' | 'browser' | 'computer' | 'notifications' | 'plugins' | 'about';
export type AgentProvider = LLMProvider;
export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills';
export type ProjectSortOrder = 'name' | 'date';
@@ -30,6 +30,7 @@ export type NotificationPreferencesState = {
channels: {
inApp: boolean;
webPush: boolean;
desktop: boolean;
sound: boolean;
};
events: {

View File

@@ -1,3 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -10,6 +11,7 @@ import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSetting
import VoiceSettingsTab from '../view/tabs/VoiceSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import BrowserUseSettingsTab from '../view/tabs/browser-use-settings/BrowserUseSettingsTab';
import ComputerUseSettingsTab from '../view/tabs/computer-use-settings/ComputerUseSettingsTab';
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
@@ -18,8 +20,22 @@ import { useSettingsController } from '../hooks/useSettingsController';
import { useWebPush } from '../../../hooks/useWebPush';
import type { SettingsProps } from '../types/types';
type DesktopNotificationsState = {
enabled: boolean;
supported: boolean;
connectedCount?: number;
targetCount?: number;
lastError?: string | null;
};
function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {
const { t } = useTranslation('settings');
const desktopNotificationsBridge = useMemo(() => (
typeof window === 'undefined'
? null
: ((window as any).cloudcliDesktopNotifications || null)
), []);
const [desktopNotificationsState, setDesktopNotificationsState] = useState<DesktopNotificationsState | null>(null);
const {
activeTab,
setActiveTab,
@@ -75,6 +91,45 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
});
};
useEffect(() => {
if (!desktopNotificationsBridge) return undefined;
let mounted = true;
desktopNotificationsBridge.getState().then((state: any) => {
if (mounted) {
setDesktopNotificationsState(state?.desktopNotifications || null);
}
}).catch(() => {});
const unsubscribe = desktopNotificationsBridge.onStateUpdated?.((state: any) => {
if (mounted) {
setDesktopNotificationsState(state?.desktopNotifications || null);
}
});
return () => {
mounted = false;
unsubscribe?.();
};
}, [desktopNotificationsBridge]);
const handleEnableDesktopNotifications = async () => {
if (!desktopNotificationsBridge) return;
const state = await desktopNotificationsBridge.update({ enabled: true });
setDesktopNotificationsState(state?.desktopNotifications || null);
setNotificationPreferences({
...notificationPreferences,
channels: { ...notificationPreferences.channels, desktop: true },
});
};
const handleDisableDesktopNotifications = async () => {
if (!desktopNotificationsBridge) return;
const state = await desktopNotificationsBridge.update({ enabled: false });
setDesktopNotificationsState(state?.desktopNotifications || null);
setNotificationPreferences({
...notificationPreferences,
channels: { ...notificationPreferences.channels, desktop: false },
});
};
if (!isOpen) {
return null;
}
@@ -144,6 +199,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
{activeTab === 'browser' && <BrowserUseSettingsTab />}
{activeTab === 'computer' && <ComputerUseSettingsTab />}
{activeTab === 'notifications' && (
<NotificationsSettingsTab
notificationPreferences={notificationPreferences}
@@ -153,6 +210,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
isPushLoading={isPushLoading}
onEnablePush={handleEnablePush}
onDisablePush={handleDisablePush}
isDesktop={Boolean(desktopNotificationsBridge)}
desktopNotifications={desktopNotificationsState}
onEnableDesktopNotifications={handleEnableDesktopNotifications}
onDisableDesktopNotifications={handleDisableDesktopNotifications}
/>
)}

View File

@@ -1,6 +1,7 @@
import { Bell, Bot, GitBranch, Info, Key, ListChecks, Mic, MonitorPlay, Palette, Puzzle } from 'lucide-react';
import { Bell, Bot, GitBranch, Info, Key, ListChecks,Mic, MonitorCog, MonitorPlay, Palette, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { COMPUTER_USE_MENUS_ENABLED } from '../../../constants/featureFlags';
import { cn } from '../../../lib/utils';
import { PillBar, Pill } from '../../../shared/view/ui';
import type { SettingsMainTab } from '../types/types';
@@ -24,11 +25,16 @@ const NAV_ITEMS: NavItem[] = [
{ id: 'voice', labelKey: 'mainTabs.voice', icon: Mic },
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
{ id: 'browser', labelKey: 'mainTabs.browser', icon: MonitorPlay },
{ id: 'computer', labelKey: 'mainTabs.computer', icon: MonitorCog },
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
{ id: 'notifications', labelKey: 'mainTabs.notifications', icon: Bell },
{ id: 'about', labelKey: 'mainTabs.about', icon: Info },
];
const VISIBLE_NAV_ITEMS = NAV_ITEMS.filter((item) => (
COMPUTER_USE_MENUS_ENABLED || item.id !== 'computer'
));
export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebarProps) {
const { t } = useTranslation('settings');
@@ -37,7 +43,7 @@ export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebar
{/* Desktop sidebar */}
<aside className="hidden w-56 flex-shrink-0 border-r border-border bg-muted/30 md:flex md:flex-col">
<nav className="flex flex-col gap-1 p-3">
{NAV_ITEMS.map((item) => {
{VISIBLE_NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
@@ -63,7 +69,7 @@ export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebar
{/* Mobile horizontal nav — pill bar */}
<div className="flex-shrink-0 border-b border-border px-3 py-2 md:hidden">
<PillBar className="scrollbar-hide w-full overflow-x-auto">
{NAV_ITEMS.map((item) => {
{VISIBLE_NAV_ITEMS.map((item) => {
const Icon = item.icon;
return (

View File

@@ -13,6 +13,16 @@ type NotificationsSettingsTabProps = {
isPushLoading: boolean;
onEnablePush: () => void;
onDisablePush: () => void;
isDesktop?: boolean;
desktopNotifications?: {
enabled: boolean;
supported: boolean;
connectedCount?: number;
targetCount?: number;
lastError?: string | null;
} | null;
onEnableDesktopNotifications?: () => void;
onDisableDesktopNotifications?: () => void;
};
export default function NotificationsSettingsTab({
@@ -23,6 +33,10 @@ export default function NotificationsSettingsTab({
isPushLoading,
onEnablePush,
onDisablePush,
isDesktop = false,
desktopNotifications = null,
onEnableDesktopNotifications,
onDisableDesktopNotifications,
}: NotificationsSettingsTabProps) {
const { t } = useTranslation('settings');
@@ -33,57 +47,107 @@ export default function NotificationsSettingsTab({
<div className="space-y-6 md:space-y-8">
<div className="space-y-4">
<div className="flex items-center gap-3">
<Bell className="w-5 h-5 text-blue-600" />
<Bell className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-medium text-foreground">{t('notifications.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('notifications.description')}</p>
</div>
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
{!pushSupported ? (
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
) : pushDenied ? (
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
) : (
<div className="flex items-center gap-3">
<button
type="button"
disabled={isPushLoading}
onClick={() => {
if (isPushSubscribed) {
onDisablePush();
} else {
onEnablePush();
}
}}
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isPushSubscribed
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
>
{isPushLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isPushSubscribed ? (
<BellOff className="w-4 h-4" />
) : (
<BellRing className="w-4 h-4" />
{isDesktop ? (
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<h4 className="font-medium text-foreground">
{t('notifications.desktop.title', { defaultValue: 'Notify this desktop app' })}
</h4>
{desktopNotifications?.supported === false ? (
<p className="text-sm text-muted-foreground">
{t('notifications.desktop.unsupported', { defaultValue: 'Desktop notifications are not supported on this system.' })}
</p>
) : (
<div className="space-y-2">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
if (desktopNotifications?.enabled) {
onDisableDesktopNotifications?.();
} else {
onEnableDesktopNotifications?.();
}
}}
className={`inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
desktopNotifications?.enabled
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
>
{desktopNotifications?.enabled ? (
<BellOff className="h-4 w-4" />
) : (
<BellRing className="h-4 w-4" />
)}
{desktopNotifications?.enabled
? t('notifications.desktop.disable', { defaultValue: 'Disable desktop notifications' })
: t('notifications.desktop.enable', { defaultValue: 'Enable desktop notifications' })}
</button>
{desktopNotifications?.enabled && (
<span className="text-sm text-green-600 dark:text-green-400">
{t('notifications.desktop.enabled', { defaultValue: 'Desktop notifications are enabled' })}
</span>
)}
</div>
{desktopNotifications?.lastError && (
<p className="text-sm text-red-600 dark:text-red-400">{desktopNotifications.lastError}</p>
)}
{isPushLoading
? t('notifications.webPush.loading')
: isPushSubscribed
? t('notifications.webPush.disable')
: t('notifications.webPush.enable')}
</button>
{isPushSubscribed && (
<span className="text-sm text-green-600 dark:text-green-400">
{t('notifications.webPush.enabled')}
</span>
)}
</div>
)}
</div>
</div>
)}
</div>
) : (
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
{!pushSupported ? (
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
) : pushDenied ? (
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
) : (
<div className="flex items-center gap-3">
<button
type="button"
disabled={isPushLoading}
onClick={() => {
if (isPushSubscribed) {
onDisablePush();
} else {
onEnablePush();
}
}}
className={`inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
isPushSubscribed
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
>
{isPushLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isPushSubscribed ? (
<BellOff className="h-4 w-4" />
) : (
<BellRing className="h-4 w-4" />
)}
{isPushLoading
? t('notifications.webPush.loading')
: isPushSubscribed
? t('notifications.webPush.disable')
: t('notifications.webPush.enable')}
</button>
{isPushSubscribed && (
<span className="text-sm text-green-600 dark:text-green-400">
{t('notifications.webPush.enabled')}
</span>
)}
</div>
)}
</div>
)}
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -133,7 +197,7 @@ export default function NotificationsSettingsTab({
</Button>
</div>
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm text-foreground">
@@ -149,7 +213,7 @@ export default function NotificationsSettingsTab({
},
})
}
className="w-4 h-4"
className="h-4 w-4"
/>
{t('notifications.events.actionRequired')}
</label>
@@ -167,7 +231,7 @@ export default function NotificationsSettingsTab({
},
})
}
className="w-4 h-4"
className="h-4 w-4"
/>
{t('notifications.events.stop')}
</label>
@@ -185,7 +249,7 @@ export default function NotificationsSettingsTab({
},
})
}
className="w-4 h-4"
className="h-4 w-4"
/>
{t('notifications.events.error')}
</label>

View File

@@ -0,0 +1,247 @@
import { useCallback, useEffect, useState } from 'react';
import { Download, Loader2, RefreshCw } from 'lucide-react';
import { Button } from '../../../../../shared/view/ui';
import { authenticatedFetch } from '../../../../../utils/api';
import SettingsCard from '../../SettingsCard';
import SettingsRow from '../../SettingsRow';
import SettingsSection from '../../SettingsSection';
import SettingsToggle from '../../SettingsToggle';
type ComputerUseSettings = {
enabled: boolean;
};
type ComputerUseStatus = {
enabled: boolean;
runtime: 'cloud' | 'local';
available: boolean;
desktopAgentConnected?: boolean;
desktopAgentCount?: number;
nutInstalled: boolean;
screenshotInstalled: boolean;
installInProgress: boolean;
message: string;
};
async function readJson<T>(response: Response): Promise<T> {
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || data.details || `Request failed (${response.status})`);
}
return data as T;
}
export default function ComputerUseSettingsTab() {
const [settings, setSettings] = useState<ComputerUseSettings>({ enabled: false });
const [status, setStatus] = useState<ComputerUseStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadState = useCallback(async () => {
setError(null);
const [settingsResponse, statusResponse] = await Promise.all([
authenticatedFetch('/api/computer-use/settings'),
authenticatedFetch('/api/computer-use/status'),
]);
const settingsData = await readJson<{ data: { settings: ComputerUseSettings } }>(settingsResponse);
const statusData = await readJson<{ data: ComputerUseStatus }>(statusResponse);
setSettings(settingsData.data.settings);
setStatus(statusData.data);
}, []);
const refreshState = useCallback(async () => {
setIsLoading(true);
try {
await loadState();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load Computer Use settings');
} finally {
setIsLoading(false);
}
}, [loadState]);
useEffect(() => {
void refreshState();
}, [refreshState]);
const updateSettings = async (nextSettings: Partial<ComputerUseSettings>) => {
setIsSaving(true);
setError(null);
try {
const response = await authenticatedFetch('/api/computer-use/settings', {
method: 'PUT',
body: JSON.stringify(nextSettings),
});
const data = await readJson<{ data: { settings: ComputerUseSettings } }>(response);
setSettings(data.data.settings);
window.dispatchEvent(new Event('computerUseSettingsChanged'));
await loadState();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save Computer Use settings');
} finally {
setIsSaving(false);
}
};
const installRuntime = async () => {
setIsInstalling(true);
setError(null);
try {
const response = await authenticatedFetch('/api/computer-use/runtime/install', { method: 'POST' });
await readJson(response);
await loadState();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to install Computer Use runtime');
} finally {
setIsInstalling(false);
}
};
const isCloud = status?.runtime === 'cloud';
const effectiveEnabled = isCloud ? status?.enabled === true : settings.enabled;
const showCloudDesktopAccess = Boolean(isCloud && effectiveEnabled);
const needsRuntime = Boolean(effectiveEnabled && !isCloud && status && (!status.nutInstalled || !status.screenshotInstalled));
const desktopAgentCount = status?.desktopAgentCount ?? (status?.desktopAgentConnected ? 1 : 0);
const modeDescription = isCloud
? 'Let cloud agents request access to your own computer through CloudCLI Desktop.'
: 'Let local agents request access to this computer.';
return (
<div className="space-y-8">
<SettingsSection
title="Computer Use"
description={modeDescription}
>
<SettingsCard divided>
<div className="flex flex-col gap-3 px-4 py-4">
<div className="rounded-md border border-amber-300/50 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
{isCloud
? 'A cloud agent can use your desktop only after you approve the request in CloudCLI Desktop. Stop ends access immediately.'
: 'Agents can use your desktop only while you grant control from the Computer tab. Stop ends access immediately.'}
</div>
{effectiveEnabled && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{isCloud
? 'Keep CloudCLI Desktop open on the computer you want agents to use.'
: 'Open the Computer tab to review requests, grant control, or stop a session.'}
</div>
)}
</div>
<SettingsRow
label="Enable Computer Use"
description={isCloud
? 'Registers Computer Use MCP servers for supported agents and allows cloud agents to request guarded access to a linked desktop.'
: 'Registers Computer Use for supported agents and allows CloudCLI to create guarded desktop control sessions on this machine.'}
>
<SettingsToggle
checked={settings.enabled}
onChange={(value) => void updateSettings({ enabled: value })}
ariaLabel="Enable Computer Use"
disabled={isLoading || isSaving}
/>
</SettingsRow>
{showCloudDesktopAccess && (
<SettingsRow
label="Cloud desktop access"
description={status?.desktopAgentConnected
? `${desktopAgentCount} ${desktopAgentCount === 1 ? 'desktop app is' : 'desktop apps are'} connected to this environment.`
: 'Not connected yet. Link happens from CloudCLI Desktop on your computer.'}
>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => void refreshState()}
disabled={isLoading}
className="h-8"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<div className={`rounded-md border px-2.5 py-1 text-xs font-medium ${
status?.desktopAgentConnected
? 'border-emerald-500/30 text-emerald-600 dark:text-emerald-300'
: 'border-amber-500/30 text-amber-600 dark:text-amber-300'
}`}
>
{status?.desktopAgentConnected
? `${desktopAgentCount} linked`
: 'Not linked'}
</div>
</div>
</SettingsRow>
)}
{(needsRuntime || showCloudDesktopAccess || error) && (
<div className="space-y-4 px-4 py-4">
{showCloudDesktopAccess && !status?.desktopAgentConnected && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-3 text-sm text-muted-foreground">
<div className="font-medium text-foreground">To link this computer</div>
<ol className="mt-2 list-decimal space-y-1 pl-5">
<li>Open CloudCLI Desktop on the computer you want agents to use.</li>
<li>Connect the same CloudCLI account used for this cloud environment.</li>
<li>Open Desktop Settings and turn on Computer Use.</li>
<li>Keep the desktop app running. This status changes to Desktop linked automatically.</li>
</ol>
</div>
)}
{showCloudDesktopAccess && status?.desktopAgentConnected && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{desktopAgentCount > 1
? `${desktopAgentCount} desktops are linked. Agents will use one available desktop; stop Computer Use on any desktop you do not want agents to control.`
: 'CloudCLI Desktop is linked. Approval prompts will appear there when an agent requests desktop access.'}
</div>
)}
{needsRuntime && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium text-foreground">Desktop runtime required</div>
<p className="text-sm text-muted-foreground">
{status?.message || 'Install the desktop control runtime needed to capture the screen and drive input.'}
</p>
<div className="flex flex-wrap gap-2 pt-1 text-xs text-muted-foreground">
<span className="rounded-md border border-border px-2 py-1">
Control lib: {status?.nutInstalled ? 'installed' : 'missing'}
</span>
<span className="rounded-md border border-border px-2 py-1">
Screen capture: {status?.screenshotInstalled ? 'installed' : 'missing'}
</span>
</div>
</div>
<Button
type="button"
size="sm"
onClick={() => void installRuntime()}
disabled={isInstalling || status?.installInProgress}
className="flex-shrink-0"
>
{isInstalling || status?.installInProgress ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isInstalling || status?.installInProgress ? 'Installing…' : 'Install Runtime'}
</Button>
</div>
)}
{error && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
{error}
</div>
)}
</div>
)}
</SettingsCard>
</SettingsSection>
</div>
);
}

View File

@@ -27,7 +27,7 @@ export default function GitHubStarBadge() {
>
<GitHubIcon className="h-3.5 w-3.5" />
<Star className="h-3 w-3" />
<span className="font-medium">Star</span>
<span className="font-normal">Star</span>
{formattedCount && (
<span className="border-l border-border/50 pl-1.5 tabular-nums">{formattedCount}</span>
)}

View File

@@ -32,7 +32,7 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
parts.push(snippet.slice(cursor));
}
return (
<span className="text-xs leading-relaxed text-muted-foreground">
<span className="min-w-0 flex-1 break-words text-xs leading-relaxed text-muted-foreground">
{parts}
</span>
);
@@ -266,7 +266,7 @@ export default function SidebarContent({
<div key={projectResult.projectName} className="space-y-1">
<div className="flex items-center gap-1.5 px-1 py-1">
<Folder className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
<span className="truncate text-xs font-medium text-foreground">
<span className="truncate text-xs font-normal text-foreground">
{projectResult.projectDisplayName}
</span>
</div>
@@ -286,7 +286,7 @@ export default function SidebarContent({
>
<div className="mb-1 flex items-center gap-1.5">
<MessageSquare className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs font-medium text-foreground">
<span className="truncate text-xs font-normal text-foreground">
{session.sessionSummary}
</span>
{session.provider && session.provider !== 'claude' && (
@@ -298,7 +298,7 @@ export default function SidebarContent({
<div className="space-y-1 pl-4">
{session.matches.map((match, idx) => (
<div key={idx} className="flex items-start gap-1">
<span className="mt-0.5 flex-shrink-0 text-[10px] font-medium uppercase text-muted-foreground/60">
<span className="mt-0.5 flex-shrink-0 text-[10px] font-normal uppercase text-muted-foreground/60">
{match.role === 'user' ? 'U' : 'A'}
</span>
<HighlightedSnippet
@@ -336,11 +336,11 @@ export default function SidebarContent({
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Activity className="h-3.5 w-3.5" />
</span>
<span className="truncate text-xs font-medium text-foreground">
<span className="truncate text-xs font-normal text-foreground">
{t('running.title', 'Running now')}
</span>
</div>
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-normal text-emerald-700 dark:text-emerald-300">
{runningSessionsCount}
</span>
</div>
@@ -395,7 +395,7 @@ export default function SidebarContent({
<div className="min-w-0">
<div className="flex items-center gap-2">
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium text-foreground">
<span className="truncate text-sm font-normal text-foreground">
{project.displayName}
</span>
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-center text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-muted-foreground">
@@ -448,7 +448,7 @@ export default function SidebarContent({
<SessionProviderLogo provider={session.__provider} className="h-3.5 w-3.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">
<span className="truncate text-xs font-normal text-foreground">
{(typeof session.summary === 'string' && session.summary.trim().length > 0
? session.summary
: typeof session.name === 'string' && session.name.trim().length > 0
@@ -484,7 +484,7 @@ export default function SidebarContent({
<div className="min-w-0">
<div className="flex items-center gap-2">
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium text-foreground">
<span className="truncate text-sm font-normal text-foreground">
{group.projectDisplayName}
</span>
{group.isProjectArchived && (
@@ -513,7 +513,7 @@ export default function SidebarContent({
<SessionProviderLogo provider={session.provider} className="h-3.5 w-3.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">
<span className="truncate text-xs font-normal text-foreground">
{session.sessionTitle}
</span>
{session.lastActivity && (

View File

@@ -70,7 +70,7 @@ export default function SidebarFooter({
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
</div>
<div className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-blue-600 dark:text-blue-300">
<span className="block truncate text-sm font-normal text-blue-600 dark:text-blue-300">
{releaseInfo?.title || `v${latestVersion}`}
</span>
<span className="text-[10px] text-blue-500/70 dark:text-blue-400/60">
@@ -91,7 +91,7 @@ export default function SidebarFooter({
<span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
</div>
<div className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm font-medium text-blue-600 dark:text-blue-300">
<span className="block truncate text-sm font-normal text-blue-600 dark:text-blue-300">
{releaseInfo?.title || `v${latestVersion}`}
</span>
<span className="text-xs text-blue-500/70 dark:text-blue-400/60">
@@ -168,7 +168,7 @@ export default function SidebarFooter({
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
<Bug className="h-4 w-4 text-muted-foreground" />
</div>
<span className="text-sm font-medium text-foreground">{t('actions.reportIssue')}</span>
<span className="text-sm font-normal text-foreground">{t('actions.reportIssue')}</span>
</a>
</div>
@@ -183,7 +183,7 @@ export default function SidebarFooter({
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
<DiscordIcon className="h-4 w-4 text-muted-foreground" />
</div>
<span className="text-sm font-medium text-foreground">{t('actions.joinCommunity')}</span>
<span className="text-sm font-normal text-foreground">{t('actions.joinCommunity')}</span>
</a>
</div>
@@ -196,7 +196,7 @@ export default function SidebarFooter({
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-background/80">
<Settings className="h-4 w-4 text-muted-foreground" />
</div>
<span className="text-sm font-medium text-foreground">{t('actions.settings')}</span>
<span className="text-sm font-normal text-foreground">{t('actions.settings')}</span>
</button>
</div>
</div>

View File

@@ -67,7 +67,7 @@ export default function SidebarHeader({
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</div>
<h1 className="truncate text-sm font-semibold tracking-tight text-foreground">{t('app.title')}</h1>
<h1 className="truncate text-sm font-normal tracking-tight text-foreground">{t('app.title')}</h1>
</div>
);
@@ -138,7 +138,7 @@ export default function SidebarHeader({
onClick={() => onSearchModeChange('projects')}
aria-pressed={searchMode === 'projects'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
searchMode === 'projects'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -151,7 +151,7 @@ export default function SidebarHeader({
onClick={() => onSearchModeChange('conversations')}
aria-pressed={searchMode === 'conversations'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
searchMode === 'conversations'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -167,7 +167,7 @@ export default function SidebarHeader({
aria-label={t('search.runningTooltip', 'Running sessions')}
title={t('search.runningTooltip', 'Running sessions')}
className={cn(
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
searchMode === 'running'
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
: "text-muted-foreground hover:text-foreground"
@@ -190,7 +190,7 @@ export default function SidebarHeader({
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
title={t('search.archiveOnlyTooltip', 'Archive only')}
className={cn(
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-normal transition-all",
searchMode === 'archived'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -278,7 +278,7 @@ export default function SidebarHeader({
onClick={() => onSearchModeChange('projects')}
aria-pressed={searchMode === 'projects'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
searchMode === 'projects'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -291,7 +291,7 @@ export default function SidebarHeader({
onClick={() => onSearchModeChange('conversations')}
aria-pressed={searchMode === 'conversations'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
searchMode === 'conversations'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -307,7 +307,7 @@ export default function SidebarHeader({
aria-label={t('search.runningTooltip', 'Running sessions')}
title={t('search.runningTooltip', 'Running sessions')}
className={cn(
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-normal transition-all",
searchMode === 'running'
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
: "text-muted-foreground hover:text-foreground"
@@ -331,7 +331,7 @@ export default function SidebarHeader({
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
title={t('search.archiveOnlyTooltip', 'Archive only')}
className={cn(
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-normal transition-all",
searchMode === 'archived'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"

View File

@@ -186,7 +186,7 @@ export default function SidebarProjectItem({
) : (
<>
<div className="flex min-w-0 flex-1 items-center justify-between">
<h3 className="truncate text-sm font-medium text-foreground">{project.displayName}</h3>
<h3 className="truncate text-sm font-normal text-foreground">{project.displayName}</h3>
{tasksEnabled && (
<TaskIndicator
status={taskStatus}
@@ -318,7 +318,7 @@ export default function SidebarProjectItem({
</div>
) : (
<div>
<div className="truncate text-sm font-semibold text-foreground" title={project.displayName}>
<div className="truncate text-sm font-normal text-foreground" title={project.displayName}>
{project.displayName}
</div>
<div className="text-xs text-muted-foreground">

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react';
import { Check, Edit2, Loader2, Trash2, X } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Badge, Button, Tooltip } from '../../../../shared/view/ui';
import { Badge, Tooltip, buttonVariants } from '../../../../shared/view/ui';
import { cn } from '../../../../lib/utils';
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
import type { SessionWithProvider } from '../../types/types';
@@ -157,7 +157,7 @@ export default function SidebarSessionItem({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
<div className="min-w-0 flex-1 truncate text-xs font-normal text-foreground">{sessionView.sessionName}</div>
{isProcessing ? (
<span className="ml-auto flex-shrink-0">
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
@@ -195,9 +195,10 @@ export default function SidebarSessionItem({
</div>
<div className="hidden md:block">
<Button
variant="ghost"
<a
href={`/session/${session.id}`}
className={cn(
buttonVariants({ variant: 'ghost' }),
'h-auto w-full justify-start rounded-md border bg-card p-2 text-left font-normal transition-all duration-150',
isSelected ? 'border-primary/20 bg-primary/5' : 'border-border/30',
!isSelected && isProcessing
@@ -206,7 +207,13 @@ export default function SidebarSessionItem({
? 'border-green-500/30 bg-green-50/5 hover:bg-green-50/10 dark:bg-green-900/5 dark:hover:bg-green-900/10'
: 'hover:bg-accent/50',
)}
onClick={() => onSessionSelect(session, project.projectId)}
// Left-click keeps in-app navigation; Ctrl/Cmd/middle-click and the
// native right-click menu use the href to open a new tab/window.
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
onSessionSelect(session, project.projectId);
}}
>
<div className="flex w-full min-w-0 items-center gap-2">
<div
@@ -219,7 +226,7 @@ export default function SidebarSessionItem({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
<div className="min-w-0 flex-1 truncate text-xs font-normal text-foreground">{sessionView.sessionName}</div>
{isProcessing ? (
<span
className={cn(
@@ -249,7 +256,7 @@ export default function SidebarSessionItem({
</div>
</div>
</div>
</Button>
</a>
<div
ref={editingContainerRef}

View File

@@ -102,7 +102,7 @@ export default function TaskIndicator({
title={indicatorConfig.title}
>
<Icon className={sizeClassNames[size]} />
<span className="font-medium">{indicatorConfig.label}</span>
<span className="font-normal">{indicatorConfig.label}</span>
</div>
);
}

View File

@@ -0,0 +1,3 @@
// TODO: Re-enable Computer Use menus after fixing the MCP server connection
// between the desktop app and the web UI.
export const COMPUTER_USE_MENUS_ENABLED = false;

View File

@@ -3,6 +3,9 @@ import type { MutableRefObject, ReactNode } from 'react';
export type PaletteOps = {
openFile: (path: string) => void;
// Opens a file in the editor side panel without changing the active tab
// (used by in-chat file links so they behave like the inline edit view).
openFileInEditor: (path: string) => void;
openSettings: (tab?: string) => void;
refreshProjects: () => Promise<void> | void;
};
@@ -13,6 +16,7 @@ const PaletteOpsContext = createContext<Registry | null>(null);
const defaultOps: PaletteOps = {
openFile: () => undefined,
openFileInEditor: () => undefined,
openSettings: () => undefined,
refreshProjects: () => undefined,
};
@@ -27,6 +31,8 @@ export function usePaletteOps(): PaletteOps {
return useMemo<PaletteOps>(
() => ({
openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path),
openFileInEditor: (path) =>
(ref?.current.openFileInEditor ?? defaultOps.openFileInEditor)(path),
openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab),
refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(),
}),
@@ -36,18 +42,20 @@ export function usePaletteOps(): PaletteOps {
export function usePaletteOpsRegister(partial: Partial<PaletteOps>) {
const ref = useContext(PaletteOpsContext);
const { openFile, openSettings, refreshProjects } = partial;
const { openFile, openFileInEditor, openSettings, refreshProjects } = partial;
useEffect(() => {
if (!ref) return undefined;
const prev = { ...ref.current };
if (openFile) ref.current.openFile = openFile;
if (openFileInEditor) ref.current.openFileInEditor = openFileInEditor;
if (openSettings) ref.current.openSettings = openSettings;
if (refreshProjects) ref.current.refreshProjects = refreshProjects;
return () => {
if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile;
if (openFileInEditor && ref.current.openFileInEditor === openFileInEditor) ref.current.openFileInEditor = prev.openFileInEditor;
if (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings;
if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects;
};
}, [ref, openFile, openSettings, refreshProjects]);
}, [ref, openFile, openFileInEditor, openSettings, refreshProjects]);
}

View File

@@ -91,4 +91,4 @@ export const ThemeProvider = ({ children }) => {
{children}
</ThemeContext.Provider>
);
};
};

View File

@@ -0,0 +1,108 @@
import { useCallback, useRef } from 'react';
import { api } from '../utils/api';
import type { Project } from '../types/app';
type FileNode = {
type: 'file' | 'directory';
name: string;
path: string;
children?: FileNode[];
};
type FlatFile = {
name: string;
path: string;
};
// `diffInfo` is intentionally `any` so this resolver can wrap editor handlers
// that expect a concrete diff payload type as well as generic callers.
type OnFileOpen = (filePath: string, diffInfo?: any) => void;
const normalize = (value: string): string => value.replace(/\\/g, '/');
const flatten = (nodes: FileNode[], out: FlatFile[]): void => {
for (const node of nodes) {
if (node.type === 'file') {
out.push({ name: node.name, path: node.path });
} else if (node.children && node.children.length > 0) {
flatten(node.children, out);
}
}
};
// References inside chat messages are often bare basenames (`foo.ts`) or partial
// paths (`utils/foo.ts`) rather than full paths, so match by path suffix and
// fall back to filename equality.
const findBestMatch = (files: FlatFile[], ref: string): string | null => {
const target = normalize(ref).replace(/^\.\//, '').replace(/^\/+/, '');
if (!target) {
return null;
}
const suffixMatch = files.find((file) => {
const filePath = normalize(file.path);
return filePath === target || filePath.endsWith(`/${target}`);
});
if (suffixMatch) {
return suffixMatch.path;
}
const base = target.split('/').pop() || target;
return files.find((file) => file.name === base)?.path ?? null;
};
/**
* Wraps an `onFileOpen` handler so a possibly bare/partial file reference is
* resolved against the project's file tree (cached per project) before the file
* is opened in the in-app editor.
*/
export function useFileOpenResolver(
selectedProject: Project | null | undefined,
onFileOpen: OnFileOpen,
): OnFileOpen {
const projectId = selectedProject?.projectId;
const cacheRef = useRef<{ projectId?: string; files: Promise<FlatFile[]> | null }>({
projectId: undefined,
files: null,
});
const loadFiles = useCallback((): Promise<FlatFile[]> => {
if (!projectId) {
return Promise.resolve([]);
}
if (cacheRef.current.projectId === projectId && cacheRef.current.files) {
return cacheRef.current.files;
}
const filesPromise = (async () => {
try {
const response = await api.getFiles(projectId);
if (!response.ok) {
return [];
}
const data = await response.json();
const tree: FileNode[] = Array.isArray(data) ? data : [];
const flat: FlatFile[] = [];
flatten(tree, flat);
return flat;
} catch {
return [];
}
})();
cacheRef.current = { projectId, files: filesPromise };
return filesPromise;
}, [projectId]);
return useCallback(
(filePath: string, diffInfo?: any) => {
const ref = normalize(filePath).trim();
void loadFiles().then((files) => {
const match = findBestMatch(files, ref);
onFileOpen(match ?? filePath, diffInfo);
});
},
[loadFiles, onFileOpen],
);
}

View File

@@ -324,7 +324,7 @@ const removeSessionFromProject = (project: Project, sessionIdToDelete: string):
return updatedProject;
};
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'browser']);
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'browser', 'computer']);
const isValidTab = (tab: string): tab is AppTab => {
return VALID_TABS.has(tab) || tab.startsWith('plugin:');
@@ -776,7 +776,7 @@ export function useProjectsState({
(session: ProjectSession) => {
setSelectedSession(session);
if (activeTab === 'tasks' || activeTab === 'browser') {
if (activeTab === 'tasks' || activeTab === 'browser' || activeTab === 'computer') {
setActiveTab('chat');
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { authenticatedFetch } from '../utils/api';
type WebPushState = {
@@ -22,7 +23,12 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
export function useWebPush(): WebPushState {
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(() => {
if (typeof window === 'undefined' || !('Notification' in window) || !('serviceWorker' in navigator)) {
if (
typeof window === 'undefined'
|| Boolean((window as any).cloudcliDesktopNotifications)
|| !('Notification' in window)
|| !('serviceWorker' in navigator)
) {
return 'unsupported';
}
return Notification.permission;

View File

@@ -23,7 +23,8 @@
"files": "Dateien",
"git": "Quellcodeverwaltung",
"tasks": "Aufgaben",
"browser": "Browser"
"browser": "Browser",
"computer": "Computer"
},
"status": {
"loading": "Lädt...",

View File

@@ -94,6 +94,7 @@
"git": "Git",
"apiTokens": "API & Token",
"tasks": "Aufgaben",
"computer": "Computer Use",
"notifications": "Benachrichtigungen",
"plugins": "Plugins",
"about": "Info"

View File

@@ -23,7 +23,8 @@
"files": "Files",
"git": "Source Control",
"tasks": "Tasks",
"browser": "Browser"
"browser": "Browser",
"computer": "Computer"
},
"status": {
"loading": "Loading...",

View File

@@ -113,6 +113,7 @@
"voice": "Voice",
"tasks": "Tasks",
"browser": "Browser",
"computer": "Computer Use",
"notifications": "Notifications",
"plugins": "Plugins",
"about": "About"
@@ -121,14 +122,21 @@
"title": "Notifications",
"description": "Control which notification events you receive.",
"webPush": {
"title": "Web Push Notifications",
"enable": "Enable Push Notifications",
"disable": "Disable Push Notifications",
"enabled": "Push notifications are enabled",
"title": "Notify this browser",
"enable": "Enable notifications",
"disable": "Disable notifications",
"enabled": "Notifications are enabled for this browser",
"loading": "Updating...",
"unsupported": "Push notifications are not supported in this browser.",
"denied": "Push notifications are blocked. Please allow them in your browser settings."
},
"desktop": {
"title": "Notify this desktop app",
"enable": "Enable notifications",
"disable": "Disable notifications",
"enabled": "Notifications are enabled for this desktop app",
"unsupported": "Desktop notifications are not supported on this system."
},
"sound": {
"title": "Sound",
"description": "Play a short tone when a chat run finishes or needs tool approval.",

View File

@@ -22,7 +22,9 @@
"shell": "Terminal",
"files": "Fichiers",
"git": "Contrôle de source",
"tasks": "Tâches"
"tasks": "Tâches",
"browser": "Navigateur",
"computer": "Ordinateur"
},
"status": {
"loading": "Chargement...",

View File

@@ -94,6 +94,7 @@
"git": "Git",
"apiTokens": "API & Tokens",
"tasks": "Tâches",
"computer": "Computer Use",
"notifications": "Notifications",
"plugins": "Plugins",
"about": "À propos"

View File

@@ -23,7 +23,8 @@
"files": "File",
"git": "Controllo Versione",
"tasks": "Attività",
"browser": "Browser"
"browser": "Browser",
"computer": "Computer"
},
"status": {
"loading": "Caricamento...",

View File

@@ -94,6 +94,7 @@
"git": "Git",
"apiTokens": "API e Token",
"tasks": "Attività",
"computer": "Computer Use",
"notifications": "Notifiche",
"plugins": "Plugin",
"about": "Informazioni"

View File

@@ -23,7 +23,8 @@
"files": "ファイル",
"git": "ソース管理",
"tasks": "タスク",
"browser": "Browser"
"browser": "Browser",
"computer": "Computer"
},
"status": {
"loading": "読み込み中...",

View File

@@ -94,6 +94,7 @@
"git": "Git",
"apiTokens": "API & トークン",
"tasks": "タスク",
"computer": "Computer Use",
"notifications": "通知",
"plugins": "プラグイン",
"about": "概要"

View File

@@ -23,7 +23,8 @@
"files": "파일",
"git": "소스 관리",
"tasks": "작업",
"browser": "Browser"
"browser": "Browser",
"computer": "Computer"
},
"status": {
"loading": "로딩 중...",

View File

@@ -94,6 +94,7 @@
"git": "Git",
"apiTokens": "API & 토큰",
"tasks": "작업",
"computer": "Computer Use",
"notifications": "알림",
"plugins": "플러그인",
"about": "정보"

View File

@@ -23,7 +23,8 @@
"files": "Файлы",
"git": "Система контроля версий",
"tasks": "Задачи",
"browser": "Browser"
"browser": "Browser",
"computer": "Компьютер"
},
"status": {
"loading": "Загрузка...",

View File

@@ -94,6 +94,7 @@
"git": "Git",
"apiTokens": "API и токены",
"tasks": "Задачи",
"computer": "Computer Use",
"notifications": "Уведомления",
"plugins": "Плагины",
"about": "О программе"

View File

@@ -23,7 +23,8 @@
"files": "Dosyalar",
"git": "Kaynak Kontrolü",
"tasks": "Görevler",
"browser": "Browser"
"browser": "Browser",
"computer": "Bilgisayar"
},
"status": {
"loading": "Yükleniyor...",

View File

@@ -94,6 +94,7 @@
"git": "Git",
"apiTokens": "API ve Token'lar",
"tasks": "Görevler",
"computer": "Bilgisayar Kullanımı",
"notifications": "Bildirimler",
"plugins": "Eklentiler",
"about": "Hakkında"

View File

@@ -23,7 +23,8 @@
"files": "文件",
"git": "源代码管理",
"tasks": "任务",
"browser": "Browser"
"browser": "浏览器",
"computer": "计算机"
},
"status": {
"loading": "加载中...",

View File

@@ -94,6 +94,7 @@
"git": "Git",
"apiTokens": "API 和令牌",
"tasks": "任务",
"computer": "计算机使用",
"notifications": "通知",
"plugins": "插件",
"about": "关于"

View File

@@ -23,7 +23,8 @@
"files": "檔案",
"git": "版本控制",
"tasks": "任務",
"browser": "Browser"
"browser": "瀏覽器",
"computer": "電腦"
},
"status": {
"loading": "載入中...",

View File

@@ -94,6 +94,7 @@
"git": "Git",
"apiTokens": "API 和權杖",
"tasks": "任務",
"computer": "電腦使用",
"notifications": "通知",
"plugins": "外掛",
"about": "關於"

View File

@@ -129,6 +129,8 @@
body {
@apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0;
padding: 0;
}
@@ -555,6 +557,30 @@
/* Mobile optimizations and components */
@layer components {
.chat-messages-pane {
contain: layout style paint;
}
.chat-composer-shell {
contain: layout style paint;
}
.chat-message {
contain: layout style paint;
content-visibility: auto;
contain-intrinsic-size: auto 180px;
}
.chat-message.assistant {
contain-intrinsic-size: auto 240px;
}
.chat-message.user,
.chat-message.tool,
.chat-message.error {
contain-intrinsic-size: auto 96px;
}
/* Mobile touch optimization and safe areas */
@media (max-width: 768px) {
* {

View File

@@ -37,7 +37,7 @@ export default function LanguageSelector({ compact = false }: LanguageSelectorPr
<select
value={i18n.language}
onChange={handleLanguageChange}
className="w-[100px] rounded-lg border border-input bg-card p-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
className="w-auto min-w-[120px] max-w-[160px] rounded-lg border border-input bg-card p-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
>
{languages.map((lang) => (
<option key={lang.value} value={lang.value}>

View File

@@ -17,7 +17,7 @@ export type ProviderModelsCacheInfo = {
source: 'memory' | 'disk' | 'fresh';
};
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'browser' | `plugin:${string}`;
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'browser' | 'computer' | `plugin:${string}`;
export interface ProjectSession {
id: string;