Compare commits

..

18 Commits

Author SHA1 Message Date
Simos Mikelatos
14e16dac58 fix: hide voice options until enabled 2026-06-26 14:06:17 +00:00
Haile
a1c48e5b1d Merge branch 'main' into fix/voice-tts-format-settings 2026-06-25 17:13:12 +03:00
Haileyesus
0e6373305b fix(voice): separate client and server backends
User-selected backend URLs must remain usable without letting clients control server requests.

Call custom providers from the browser while keeping the server proxy bound to its configured host.

This restores voice controls for frontend settings without reopening the SSRF path.
2026-06-25 17:10:42 +03:00
Haileyesus
43c0cca96e fix(voice): validate config and request boundaries
Malformed stored settings could break voice requests instead of using safe defaults.

Health results could outlive auth changes. URL checks also did not guard the fetch sink.

Remove constant recorder branches so lifecycle cancellation stays clear.
2026-06-25 16:52:54 +03:00
Haileyesus
af16d8ebdc fix(voice): harden recording and backend behavior
Redirects could bypass the backend URL guard, and TTS playback waited for full buffering.

Recording could overlap or finish after teardown. Controls also ignored backend readiness.

Explicit formats and config-aware cache keys prevent stale audio after settings change.
2026-06-25 16:35:30 +03:00
Haile
b0a49120cc Merge branch 'main' into fix/voice-tts-format-settings 2026-06-25 15:56:07 +03:00
Haileyesus
8cbfac6ab1 fix(voice): expose TTS format in user settings 2026-06-24 10:05:35 +03:00
newsbubbles
9919851be7 Merge upstream/main into feat/voice (resolve voice vs browser-use + websocket-unify) 2026-06-20 16:17:24 +01:00
Haile
66b0766013 Merge branch 'main' into feat/voice 2026-06-15 15:27:18 +03:00
newsbubbles
95a75aac47 fix(voice): make stop() idempotent so a double tap can't throw
guard on the recorder's own state instead of react state, so a double tap or
the mic and send buttons both firing won't call stop() on an already-inactive
MediaRecorder.
2026-06-13 13:34:18 +01:00
newsbubbles
952ddb9eb7 feat(voice): send transcript with the main send button while recording
while dictating, the main send button stops recording, transcribes, and sends
in one tap, matching the codex-style flow. the mic button still stops and drops
the transcript into the input box to edit before sending. voice recording state
is lifted into the composer so both buttons share it, and the send button is
enabled (not grayed) while recording. also fix a pre-existing type error: the
quick-settings preferences map was missing voiceEnabled.
2026-06-13 13:09:14 +01:00
newsbubbles
7f8ae7023d fix(voice): harden timeout parsing, tts input check, and player abort
- fall back to the default when VOICE_TIMEOUT_MS is non-numeric or <= 0, so a
  bad override can't make the abort fire immediately
- type-check the tts `text` before calling .trim() so a non-string body returns
  400 instead of throwing
- abort the in-flight TTS fetch on stop() and on a superseding play, so tapping
  read-aloud repeatedly doesn't leave orphaned requests generating audio
2026-06-13 12:09:51 +01:00
newsbubbles
1203760ba8 docs(voice): provider-agnostic wording and jsdoc on proxy functions
drop leftover sidecar/faster-whisper references now that the backend is any
openai-compatible voice api, and add jsdoc to the voice-proxy functions so the
docstring coverage check passes.
2026-06-13 11:55:46 +01:00
newsbubbles
f285715e31 fix(voice): address review (SSRF guard, auth mapping, client timeout)
Validates the user-supplied backend URL (http/https only, blocks the link-local
metadata range) to prevent SSRF; remaps upstream 401/403 so a bad voice API key
isn't read as the app's own auth failing; adds a client-side AbortController timeout
on the read-aloud request so the button can't sit in loading if a request stalls.
2026-06-10 17:56:02 +01:00
newsbubbles
cb3ad16139 fix(voice): play read-aloud through an app-level player to stop cutoffs
Read-aloud now runs in a single module-level player outside the React tree instead
of per-message component state. Switching chats or re-rendering a message no longer
revokes the blob URL mid-play (the 'Invalid URI' cutoff). Adds content-keyed caching so
re-listening doesn't regenerate, and reuses one audio element (also unlocks iOS once).
2026-06-09 15:19:36 +01:00
newsbubbles
32a6405537 fix(voice): relax backend timeout and surface timeout errors
Bumps the proxy timeout to 5 minutes (VOICE_TIMEOUT_MS) since local TTS can
synthesize long messages at roughly real-time, and returns a clear timed-out
message (504) instead of failing silently. The read-aloud button now shows
backend errors.
2026-06-09 12:13:06 +01:00
newsbubbles
711936d279 refactor(voice): provider-agnostic backend and in-app config
Switches the voice proxy to the OpenAI audio API (/v1/audio/transcriptions and
/v1/audio/speech) so it works with OpenAI, Groq, or a local server. Adds a
Settings -> Voice tab (base URL, API key, models, voice) plus a Quick Settings
toggle, and removes the bundled Python sidecar.

Review fixes: stop mic tracks on unmount, clear the global TTS stop handler and
revoke leaked blob URLs, add fetch timeouts in the proxy, surface mic errors in
the button, trim before appending transcripts, and drop the repo-wide wav ignore.
2026-06-09 10:05:06 +01:00
newsbubbles
d05585e1f4 feat(voice): add optional speech-to-text input and read-aloud TTS
Adds a push-to-talk mic button in the composer and a read-aloud button on
assistant messages. Both are opt-in and hidden unless a voice backend is
configured via VOICE_SIDECAR_URL.

The auth-gated /api/voice proxy forwards to a configurable backend exposing
/transcribe and /tts (provider-agnostic); the frontend probes /api/voice/health
and hides the controls when disabled. Adds i18n keys and docs/voice.md.

Includes a local, no-API-key reference backend in voice-sidecar/ (faster-whisper
for STT, Kokoro-82M for TTS, both CPU-capable).
2026-06-08 00:48:24 +01:00
17 changed files with 229 additions and 809 deletions

View File

@@ -207,15 +207,6 @@ 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, BashCommandDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import { OneLineDisplay, 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,31 +125,6 @@ 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 = parsedData?.command || '';
const description = parsedData?.description;
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

@@ -1,155 +0,0 @@
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}
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

@@ -1,124 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { ChevronRight, Terminal } from 'lucide-react';
import { cn } from '../../../../lib/utils';
import type { ChatMessage } from '../../types/types';
import { BashCommandDisplay } from './BashCommandDisplay';
import { ToolStatusBadge } from './ToolStatusBadge';
import type { ToolStatus } from './ToolStatusBadge';
interface CommandRunGroupProps {
messages: ChatMessage[];
}
type ExtractedCommand = {
key: string;
command: string;
description?: string;
output: string;
isError: boolean;
status: ToolStatus;
};
function extractCommand(message: ChatMessage, index: number): ExtractedCommand {
let command = '';
let description: string | undefined;
try {
const parsed =
typeof message.toolInput === 'string' ? JSON.parse(message.toolInput) : message.toolInput;
command = parsed?.command || '';
description = parsed?.description;
} catch {
command = typeof message.toolInput === 'string' ? message.toolInput : '';
}
const result = message.toolResult;
const rawContent = result?.content;
const output =
typeof rawContent === 'string' ? rawContent : rawContent != null ? String(rawContent) : '';
const isError = Boolean(result?.isError);
const status: ToolStatus = !result ? 'running' : isError ? 'error' : 'completed';
return {
key: message.toolId || `${command}-${index}`,
command,
description,
output,
isError,
status,
};
}
/**
* Groups a run of consecutive shell commands under a single collapsible header
* (Codex-in-VSCode style). Collapsed by default so long command runs stay tidy;
* expanding reveals every command in the run, each independently expandable for
* its own output.
*/
export const CommandRunGroup: React.FC<CommandRunGroupProps> = ({ messages }) => {
const commands = messages.map(extractCommand);
const count = commands.length;
const anyRunning = commands.some((c) => c.status === 'running');
const anyError = commands.some((c) => c.isError);
const [open, setOpen] = useState(false);
// Surface failed runs without a click: open once when an error first appears.
const autoAppliedRef = useRef(false);
useEffect(() => {
if (!autoAppliedRef.current && anyError) {
autoAppliedRef.current = true;
setOpen(true);
}
}, [anyError]);
return (
<div
className={cn(
'overflow-hidden rounded-xl border bg-muted/30 transition-all duration-200',
anyError ? 'border-red-500/30' : 'border-border/60',
open && 'shadow-sm',
)}
>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((prev) => !prev)}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left outline-none transition-colors hover:bg-muted/50 focus-visible:ring-1 focus-visible:ring-ring"
>
<ChevronRight
className={cn(
'h-4 w-4 flex-shrink-0 text-muted-foreground/70 transition-transform duration-200',
open && 'rotate-90',
)}
/>
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-md bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Terminal className="h-3.5 w-3.5" />
</span>
<span className="flex-1 text-xs font-medium text-foreground">
{anyRunning ? 'Running' : 'Ran'} <span className="text-muted-foreground">{count} commands</span>
</span>
{anyRunning && (
<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" />
)}
{anyError && <ToolStatusBadge status="error" className="flex-shrink-0" />}
</button>
{open && (
<div className="settings-content-enter space-y-1 border-t border-border/50 p-2">
{commands.map((cmd) => (
<BashCommandDisplay
key={cmd.key}
command={cmd.command}
description={cmd.description}
output={cmd.output}
isError={cmd.isError}
status={cmd.status !== 'completed' ? cmd.status : undefined}
defaultOpen={false}
/>
))}
</div>
)}
</div>
);
};

View File

@@ -1,77 +0,0 @@
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,11 +15,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
}) => {
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
// 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) {
if (!questions || questions.length === 0) {
return null;
}
@@ -28,23 +24,11 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
return (
<div className={`space-y-2 ${className}`}>
{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;
{questions.map((q, idx) => {
const answer = answers?.[q.question];
// `answer` may be a non-string (or absent) in malformed payloads.
const answerLabels = typeof answer === 'string' ? answer.split(', ') : [];
const answerLabels = answer ? 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
@@ -90,7 +74,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 = !options.some(o => o.label === lbl);
const isCustom = !q.options.some(o => o.label === lbl);
return (
<span
key={lbl}
@@ -126,7 +110,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">
{options.map((opt) => {
{q.options.map((opt) => {
const wasSelected = answerLabels.includes(opt.label);
return (
<div
@@ -164,7 +148,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
);
})}
{answerLabels.filter(lbl => !options.some(o => o.label === lbl)).map(lbl => (
{answerLabels.filter(lbl => !q.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,8 +1,6 @@
export { CollapsibleSection } from './CollapsibleSection';
export { ToolDiffViewer } from './ToolDiffViewer';
export { OneLineDisplay } from './OneLineDisplay';
export { BashCommandDisplay } from './BashCommandDisplay';
export { CommandRunGroup } from './CommandRunGroup';
export { CollapsibleDisplay } from './CollapsibleDisplay';
export { SubagentContainer } from './SubagentContainer';
export * from './ContentRenderers';

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { useCallback, useRef } from 'react';
import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react';
import type { ChatMessage } from '../../types/types';
import type {
@@ -13,7 +13,6 @@ import { getIntrinsicMessageKey } from '../../utils/messageKeys';
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import { CommandRunGroup } from '../../tools';
interface ChatMessagesPaneProps {
scrollContainerRef: RefObject<HTMLDivElement>;
@@ -253,81 +252,25 @@ export default function ChatMessagesPane({
</div>
)}
{(() => {
const isBashCommand = (m: ChatMessage | null | undefined) =>
Boolean(m && m.isToolUse && m.toolName === 'Bash' && !m.isSubagentContainer);
// Messages that render nothing (e.g. thinking hidden when showThinking
// is off) shouldn't break a visual run of commands.
const isRendered = (m: ChatMessage) => !(m.isThinking && !showThinking);
const items: ReactNode[] = [];
for (let index = 0; index < visibleMessages.length; index++) {
const message = visibleMessages[index];
// Collapse a run of 2+ consecutive shell commands under a single
// header so long command runs stay tidy (Codex-in-VSCode style).
// Skip over non-rendered messages (e.g. hidden reasoning that Codex
// interleaves between commands) so they don't split the run.
if (isBashCommand(message)) {
const runIndices = [index];
let cursor = index + 1;
while (cursor < visibleMessages.length) {
const candidate = visibleMessages[cursor];
if (!isRendered(candidate)) {
cursor++;
continue;
}
if (isBashCommand(candidate)) {
runIndices.push(cursor);
cursor++;
continue;
}
break;
}
if (runIndices.length >= 2) {
const groupMessages = runIndices.map((i) => visibleMessages[i]);
items.push(
<CommandRunGroup key={getMessageKey(groupMessages[0])} messages={groupMessages} />,
);
// Consume everything up to the last command in the run (any
// trailing skipped messages render nothing anyway).
index = runIndices[runIndices.length - 1];
continue;
}
}
// Walk back past messages that are not actually rendered (e.g. thinking
// messages hidden when showThinking is off). Otherwise a hidden thinking
// message would make the following message look "grouped" and suppress its
// provider header/icon — which is why Claude turns lost their icon.
let prevMessage: ChatMessage | null = null;
for (let i = index - 1; i >= 0; i--) {
const candidate = visibleMessages[i];
if (candidate.isThinking && !showThinking) continue;
prevMessage = candidate;
break;
}
items.push(
<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}
/>,
);
}
return items;
})()}
{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}
/>
);
})}
</>
)}
</div>

View File

@@ -2,7 +2,9 @@ import { useMemo, useState } from 'react';
import {
Activity,
BadgeCheck,
Check,
CircleHelp,
Clipboard,
Coins,
Cpu,
Gauge,
@@ -57,6 +59,19 @@ 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',
@@ -231,6 +246,7 @@ function HelpContent({ data }: { data: HelpCommandData }) {
function ModelsContent({
data,
providerModelCatalog,
providerModelCacheCatalog,
providerModelsRefreshing,
onHardRefreshProviderModels,
currentSessionId,
@@ -238,12 +254,14 @@ 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);
@@ -251,6 +269,7 @@ 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;
@@ -263,6 +282,7 @@ 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();
@@ -276,8 +296,18 @@ function ModelsContent({
});
}, [availableOptions, query]);
const activeOption = availableOptions.find((option) => option.value === currentModel);
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
const showSearch = availableOptions.length > 6;
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 handleSelectModel = async (model: string) => {
setChangingModel(model);
@@ -300,106 +330,162 @@ function ModelsContent({
};
return (
<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 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>
{showSearch && (
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
)}
<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>
{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 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="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="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>
</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>
{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>
</div>
) : (
'Your choice becomes the default model for new turns.'
<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>
)}
</p>
</div>
</div>
);
}
@@ -520,6 +606,7 @@ export default function CommandResultModal({
payload,
onClose,
providerModelCatalog,
providerModelCacheCatalog,
providerModelsRefreshing,
onHardRefreshProviderModels,
currentSessionId,
@@ -537,9 +624,9 @@ export default function CommandResultModal({
icon: CircleHelp,
},
models: {
eyebrow: 'Model selection',
title: 'Choose a Model',
subtitle: 'Pick the model this provider should use.',
eyebrow: 'Model inventory',
title: 'Available Models',
subtitle: 'Browse, search, and copy model IDs for the active provider.',
icon: Cpu,
},
cost: {
@@ -613,6 +700,7 @@ export default function CommandResultModal({
<ModelsContent
data={payload.data as ModelCommandData}
providerModelCatalog={providerModelCatalog}
providerModelCacheCatalog={providerModelCacheCatalog}
providerModelsRefreshing={providerModelsRefreshing}
onHardRefreshProviderModels={onHardRefreshProviderModels}
currentSessionId={currentSessionId}

View File

@@ -8,48 +8,12 @@ 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;
@@ -159,6 +123,11 @@ 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">
@@ -178,50 +147,10 @@ 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={components as any}>
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
{content}
</ReactMarkdown>
</div>

View File

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

View File

@@ -11,7 +11,6 @@ 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 { authenticatedFetch } from '../../../utils/api';
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
import EditorSidebar from '../../code-editor/view/EditorSidebar';
@@ -78,10 +77,6 @@ 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.
@@ -126,10 +121,6 @@ function MainContent({
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) {

View File

@@ -32,7 +32,7 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
parts.push(snippet.slice(cursor));
}
return (
<span className="min-w-0 flex-1 break-words text-xs leading-relaxed text-muted-foreground">
<span className="text-xs leading-relaxed text-muted-foreground">
{parts}
</span>
);

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, Tooltip, buttonVariants } from '../../../../shared/view/ui';
import { Badge, Button, Tooltip } from '../../../../shared/view/ui';
import { cn } from '../../../../lib/utils';
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
import type { SessionWithProvider } from '../../types/types';
@@ -195,10 +195,9 @@ export default function SidebarSessionItem({
</div>
<div className="hidden md:block">
<a
href={`/session/${session.id}`}
<Button
variant="ghost"
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
@@ -207,13 +206,7 @@ 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',
)}
// 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);
}}
onClick={() => onSessionSelect(session, project.projectId)}
>
<div className="flex w-full min-w-0 items-center gap-2">
<div
@@ -256,7 +249,7 @@ export default function SidebarSessionItem({
</div>
</div>
</div>
</a>
</Button>
<div
ref={editingContainerRef}

View File

@@ -3,9 +3,6 @@ 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;
};
@@ -16,7 +13,6 @@ const PaletteOpsContext = createContext<Registry | null>(null);
const defaultOps: PaletteOps = {
openFile: () => undefined,
openFileInEditor: () => undefined,
openSettings: () => undefined,
refreshProjects: () => undefined,
};
@@ -31,8 +27,6 @@ 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)(),
}),
@@ -42,20 +36,18 @@ export function usePaletteOps(): PaletteOps {
export function usePaletteOpsRegister(partial: Partial<PaletteOps>) {
const ref = useContext(PaletteOpsContext);
const { openFile, openFileInEditor, openSettings, refreshProjects } = partial;
const { openFile, 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, openFileInEditor, openSettings, refreshProjects]);
}, [ref, openFile, openSettings, refreshProjects]);
}

View File

@@ -1,108 +0,0 @@
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

@@ -37,7 +37,7 @@ export default function LanguageSelector({ compact = false }: LanguageSelectorPr
<select
value={i18n.language}
onChange={handleLanguageChange}
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"
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"
>
{languages.map((lang) => (
<option key={lang.value} value={lang.value}>