Merge origin/main into deferred OpenCode restart branch.

Resolve ProvidersPage and lifecycle conflicts with custom providers and
AppImage ARGV0 stripping. Address review follow-ups: OAuth index helper +
tests, single auth-methods load trigger, shared Google env-alias module with
VS Code parity coverage, and deferred restart for custom provider upsert.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-03 13:57:05 +00:00
co-authored by Serhii Dziupin
94 changed files with 5949 additions and 372 deletions
@@ -111,7 +111,15 @@ const EMPTY_QUEUE: QueuedMessage[] = [];
export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: QueuedMessageChipsProps) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
// Must use the same resolution the composer used to build the queue key —
// reading currentSessionDirectory raw can key the chips to a different
// directory than the one the messages were queued under.
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const target = currentSessionId ? createMessageQueueTarget(currentSessionId, currentSessionDirectory) : null;
const queueKey = target ? getMessageQueueKey(target) : null;
const queuedMessages = useMessageQueueStore(
@@ -59,7 +59,7 @@ Use this doc when you ask an agent to change tool/header/description behavior.
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output bypasses the throttle and receives the normal one-time highlighted rendering.
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
## "I want to change description for Perplexity" (example recipe)
@@ -1,24 +1,109 @@
import { describe, expect, test } from 'bun:test';
import { getStreamingOutputAppend, getToolOutput, renderTerminalOutput } from './toolOutput';
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
import { tryParseJsonOutput } from '../toolRenderers';
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
import { getStreamingOutputAppend, getToolOutput } from './toolOutput';
import { getToolDescriptionFallback } from './toolRenderUtils';
describe('getToolOutput', () => {
test('prefers authoritative state output', () => {
expect(getToolOutput('bash', 'final output', 'streamed output')).toBe('final output');
expect(getToolOutput('bash', '', 'streamed output')).toBe('');
test('prefers state.output for completed tools', () => {
expect(getToolOutput('bash', 'final output', 'partial output', 'completed')).toBe('final output');
});
test('falls back to streamed metadata output for bash', () => {
expect(getToolOutput('bash', undefined, 'streamed output')).toBe('streamed output');
expect(getToolOutput('bash', undefined, '')).toBe(undefined);
test('normalizes completed bash state output while preserving final-output precedence', () => {
expect(getToolOutput('bash', '\u001B[32mFinal output\u001B[0m', 'partial output', 'completed')).toBe('Final output');
});
test('does not expose metadata output for other tools', () => {
expect(getToolOutput('read', undefined, 'metadata output')).toBe(undefined);
test('falls back to metadata.output for bash tools without state output', () => {
expect(getToolOutput('bash', undefined, 'partial output', 'completed')).toBe('partial output');
});
test('normalizes bash metadata output for completed state', () => {
expect(getToolOutput('bash', undefined, 'Progress 10%\r\u001B[2KProgress 90%', 'completed')).toBe('Progress 90%');
});
test('does not normalize bash output while running', () => {
expect(getToolOutput('bash', '\u001B[32mRunning\u001B[0m', undefined, 'running')).toBe('\u001B[32mRunning\u001B[0m');
expect(getToolOutput('bash', undefined, 'Progress\r\u001B[2K', 'running')).toBe('Progress\r\u001B[2K');
});
test('ignores metadata.output for non-bash tools', () => {
expect(getToolOutput('read', undefined, 'partial output', 'completed')).toBe(undefined);
expect(getToolOutput('read', 'final output', 'partial output', 'completed')).toBe('final output');
});
test('returns undefined when bash has no output', () => {
expect(getToolOutput('bash', undefined, undefined, 'completed')).toBe(undefined);
});
test('ignores empty metadata.output for bash', () => {
expect(getToolOutput('bash', undefined, '', 'completed')).toBe(undefined);
});
});
describe('renderTerminalOutput', () => {
test('renders carriage-return progress updates as their latest value', () => {
expect(renderTerminalOutput('Downloading 10%\r\u001B[2KDownloading 90%')).toBe('Downloading 90%');
});
test('removes ANSI styles while preserving the output text', () => {
expect(renderTerminalOutput('\u001B[32mComplete\u001B[0m\n')).toBe('Complete\n');
});
test('applies cursor-up progress updates to the prior line', () => {
expect(renderTerminalOutput('First\nWorking\u001B[1A\r\u001B[2KDone\n')).toBe('Done\nWorking');
});
test('CSI K erases from cursor to end of line', () => {
expect(renderTerminalOutput('Hello World\u001B[5G\u001B[K')).toBe('Hell');
});
test('CSI 1 K erases from beginning of line through cursor, preserving suffix', () => {
expect(renderTerminalOutput('Hello World\u001B[6G\u001B[1K')).toBe(' World');
});
test('CSI 2 K erases entire line', () => {
expect(renderTerminalOutput('Hello World\u001B[2K')).toBe('');
});
test('handles large single-line output without quadratic slowdown', () => {
const largeLine = 'A'.repeat(50000) + '\u001B[0m';
const start = performance.now();
const result = renderTerminalOutput(largeLine);
const elapsed = performance.now() - start;
expect(result).toBe('A'.repeat(50000));
expect(elapsed).toBeLessThan(1000);
});
test('bounds synthetic rows from large cursor coordinates', () => {
const result = renderTerminalOutput('\u001B[999999999Bdone');
expect(result.endsWith('done')).toBe(true);
expect(result.length <= 100_004).toBe(true);
});
test('bounds synthetic columns from large cursor coordinates', () => {
const result = renderTerminalOutput('\u001B[999999999Cdone');
expect(result.endsWith('done')).toBe(true);
expect(result.length <= 100_004).toBe(true);
});
test('shares the synthetic allocation budget across cursor movements', () => {
const result = renderTerminalOutput('\u001B[50001B\u001B[999999999Cdone');
expect(result.endsWith('done')).toBe(true);
expect(result.length <= 100_004).toBe(true);
});
test('bounds absolute cursor row and column coordinates', () => {
const result = renderTerminalOutput('\u001B[999999999;999999999Hdone');
expect(result.endsWith('done')).toBe(true);
expect(result.length <= 100_004).toBe(true);
});
test('bounds absolute cursor columns', () => {
const result = renderTerminalOutput('\u001B[999999999Gdone');
expect(result.endsWith('done')).toBe(true);
expect(result.length <= 100_004).toBe(true);
});
});
@@ -1362,7 +1362,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
const rawOutput = getToolOutput(part.tool, stateWithData.output, metadata?.output);
const rawOutput = getToolOutput(part.tool, stateWithData.output, metadata?.output, state.status);
const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
const rawOutputString = typeof rawOutput === 'string' ? rawOutput : '';
const isStreamingBash = part.tool === 'bash' && state.status === 'running';
@@ -1,14 +1,153 @@
const MAX_SYNTHETIC_TERMINAL_CELLS = 100_000;
interface TerminalRenderBudget {
syntheticCells: number;
}
const ensureLine = (lines: string[][], requestedRow: number, budget: TerminalRenderBudget): number => {
const missingRows = Math.max(0, requestedRow - lines.length + 1);
const availableCells = MAX_SYNTHETIC_TERMINAL_CELLS - budget.syntheticCells;
const addedRows = Math.min(missingRows, availableCells);
const row = Math.min(requestedRow, lines.length + addedRows - 1);
while (lines.length <= row) {
lines.push([]);
}
budget.syntheticCells += addedRows;
return row;
};
const writeTerminalCharacter = (
lines: string[][],
row: number,
requestedColumn: number,
character: string,
budget: TerminalRenderBudget,
): number => {
const line = lines[row];
const availableCells = MAX_SYNTHETIC_TERMINAL_CELLS - budget.syntheticCells;
const column = Math.min(requestedColumn, line.length + availableCells);
const padding = Math.max(0, column - line.length);
while (line.length < column) {
line.push(' ');
}
budget.syntheticCells += padding;
line[column] = character;
return column;
};
export const renderTerminalOutput = (output: string): string => {
if (!output.includes('\u001B') && !output.includes('\r') && !output.includes('\b')) {
return output;
}
const lines: string[][] = [[]];
const budget: TerminalRenderBudget = { syntheticCells: 0 };
let row = 0;
let column = 0;
for (let index = 0; index < output.length; index += 1) {
const character = output[index];
if (character === '\n') {
row += 1;
column = 0;
lines[row] ??= [];
continue;
}
if (character === '\r') {
column = 0;
continue;
}
if (character === '\b') {
column = Math.max(0, column - 1);
continue;
}
if (character !== '\u001B') {
column = writeTerminalCharacter(lines, row, column, character, budget) + 1;
continue;
}
const nextCharacter = output[index + 1];
if (nextCharacter === '[') {
const sequenceStart = index + 2;
let sequenceEnd = sequenceStart;
while (sequenceEnd < output.length && !/[\x40-\x7E]/.test(output[sequenceEnd])) {
sequenceEnd += 1;
}
if (sequenceEnd === output.length) {
break;
}
const command = output[sequenceEnd];
const parameters = output.slice(sequenceStart, sequenceEnd).split(';').map((value) => Number.parseInt(value, 10) || 0);
const count = parameters[0] || 1;
if (command === 'A') {
row = Math.max(0, row - count);
} else if (command === 'B') {
row = ensureLine(lines, row + count, budget);
} else if (command === 'C') {
column += count;
} else if (command === 'D') {
column = Math.max(0, column - count);
} else if (command === 'G') {
column = Math.max(0, count - 1);
} else if (command === 'H' || command === 'f') {
row = ensureLine(lines, Math.max(0, (parameters[0] || 1) - 1), budget);
column = Math.max(0, (parameters[1] || 1) - 1);
} else if (command === 'K') {
const line = lines[row];
const mode = parameters[0];
if (mode === 1) {
for (let i = 0; i <= column && i < line.length; i += 1) {
line[i] = ' ';
}
} else if (mode === 2) {
lines[row] = [];
} else {
line.length = Math.min(line.length, column);
}
}
index = sequenceEnd;
continue;
}
if (nextCharacter === ']') {
const terminator = output.indexOf('\u0007', index + 2);
const stringTerminator = output.indexOf('\u001B\\', index + 2);
const end = terminator === -1
? stringTerminator
: stringTerminator === -1
? terminator
: Math.min(terminator, stringTerminator);
if (end === -1) {
break;
}
index = output[end] === '\u0007' ? end : end + 1;
continue;
}
index += 1;
}
return lines.map((line) => line.join('')).join('\n');
};
export const getToolOutput = (
tool: string,
stateOutput: unknown,
metadataOutput: unknown,
status?: string,
): string | undefined => {
const isBash = tool === 'bash';
const shouldNormalize = isBash && status !== 'running';
if (typeof stateOutput === 'string') {
return stateOutput;
return shouldNormalize ? renderTerminalOutput(stateOutput) : stateOutput;
}
if (tool === 'bash' && typeof metadataOutput === 'string' && metadataOutput.length > 0) {
return metadataOutput;
if (isBash && typeof metadataOutput === 'string' && metadataOutput.length > 0) {
return shouldNormalize ? renderTerminalOutput(metadataOutput) : metadataOutput;
}
return undefined;
@@ -0,0 +1,396 @@
import React from 'react';
import {
SettingsSection,
SettingsStackedField,
SETTINGS_FIELDS_STACK_CLASS,
SETTINGS_FIELD_LABEL_CLASS,
SETTINGS_HELPER_CLASS,
SETTINGS_ICON_BUTTON_CLASS,
SETTINGS_CONTROL_CLUSTER_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import {
createEmptyCustomProviderForm,
createHeaderRow,
createModelRow,
validateCustomProvider,
type CustomProviderFormState,
type CustomProviderPersistPlan,
type CustomProviderTranslator,
type FieldErrors,
type HeaderFieldErrors,
type ModelFieldErrors,
} from './custom-provider-form';
type CustomProviderFormProps = {
existingProviderIDs: ReadonlySet<string>;
disabledProviders?: readonly string[];
busy?: boolean;
mode?: 'create' | 'edit';
initialValues?: CustomProviderFormState;
allowExistingAuth?: boolean;
authFailureHint?: string | null;
onSubmit: (plan: CustomProviderPersistPlan) => void | Promise<void>;
onCancel?: () => void;
onDisconnect?: () => void | Promise<void>;
};
export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
existingProviderIDs,
disabledProviders = [],
busy = false,
mode = 'create',
initialValues,
allowExistingAuth = false,
authFailureHint = null,
onSubmit,
onCancel,
onDisconnect,
}) => {
const { t } = useI18n();
const isEdit = mode === 'edit';
const [form, setForm] = React.useState<CustomProviderFormState>(
() => initialValues ?? createEmptyCustomProviderForm(),
);
const [err, setErr] = React.useState<FieldErrors>({});
const [modelErrors, setModelErrors] = React.useState<ModelFieldErrors[]>([]);
const [headerErrors, setHeaderErrors] = React.useState<HeaderFieldErrors[]>([]);
const seededEditProviderIdRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!initialValues) {
return;
}
// Edit mode: seed once per provider id so parent re-renders (new object
// identity for the same snapshot) do not wipe in-progress edits.
if (isEdit && seededEditProviderIdRef.current === initialValues.providerID) {
return;
}
seededEditProviderIdRef.current = isEdit ? initialValues.providerID : null;
setForm(initialValues);
setErr({});
setModelErrors([]);
setHeaderErrors([]);
}, [initialValues, isEdit]);
const setField = (key: keyof Pick<CustomProviderFormState, 'providerID' | 'name' | 'baseURL' | 'apiKey'>, value: string) => {
setForm((prev) => ({ ...prev, [key]: value }));
setErr((prev) => ({ ...prev, [key]: undefined }));
};
const setModel = (index: number, key: 'id' | 'name', value: string) => {
setForm((prev) => ({
...prev,
models: prev.models.map((row, rowIndex) => (rowIndex === index ? { ...row, [key]: value } : row)),
}));
setModelErrors((prev) => {
const next = [...prev];
next[index] = { ...(next[index] ?? {}), [key]: undefined };
return next;
});
};
const setHeader = (index: number, key: 'key' | 'value', value: string) => {
setForm((prev) => ({
...prev,
headers: prev.headers.map((row, rowIndex) => (rowIndex === index ? { ...row, [key]: value } : row)),
}));
setHeaderErrors((prev) => {
const next = [...prev];
next[index] = { ...(next[index] ?? {}), [key]: undefined };
return next;
});
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (busy) {
return;
}
const output = validateCustomProvider({
form,
t: ((key, vars) => t(key as Parameters<typeof t>[0], vars)) as CustomProviderTranslator,
existingProviderIDs,
disabledProviders,
editingProviderID: isEdit ? form.providerID : undefined,
allowExistingAuth: isEdit && allowExistingAuth,
});
setErr(output.err);
setModelErrors(output.models);
setHeaderErrors(output.headers);
if (!output.result) {
return;
}
await onSubmit(output.result);
};
return (
<form onSubmit={handleSubmit} className="space-y-0">
<SettingsSection
title={isEdit ? t('settings.providers.page.custom.editTitle') : t('settings.providers.page.custom.title')}
divider={false}
settingsItem="providers.custom"
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
>
<p className={SETTINGS_HELPER_CLASS}>{t('settings.providers.page.custom.description')}</p>
{authFailureHint ? (
<p className="typography-meta text-[var(--status-warning)]" role="status">
{authFailureHint}
</p>
) : null}
<SettingsStackedField
label={t('settings.providers.page.custom.field.providerID.label')}
info={t('settings.providers.page.custom.field.providerID.info')}
>
<Input
value={form.providerID}
onChange={(event) => setField('providerID', event.target.value)}
placeholder={t('settings.providers.page.custom.field.providerID.placeholder')}
className="h-8 rounded-md px-3 font-mono text-xs"
autoFocus={!isEdit}
disabled={isEdit || busy}
aria-invalid={Boolean(err.providerID)}
aria-label={t('settings.providers.page.custom.field.providerID.label')}
/>
{err.providerID ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.providerID}</p> : null}
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.name.label')}
info={t('settings.providers.page.custom.field.name.info')}
>
<Input
value={form.name}
onChange={(event) => setField('name', event.target.value)}
placeholder={t('settings.providers.page.custom.field.name.placeholder')}
className="h-8 rounded-md px-3"
aria-invalid={Boolean(err.name)}
aria-label={t('settings.providers.page.custom.field.name.label')}
/>
{err.name ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.name}</p> : null}
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.baseURL.label')}
info={t('settings.providers.page.custom.field.baseURL.info')}
>
<Input
value={form.baseURL}
onChange={(event) => setField('baseURL', event.target.value)}
placeholder={t('settings.providers.page.custom.field.baseURL.placeholder')}
className="h-8 rounded-md px-3 font-mono text-xs"
aria-invalid={Boolean(err.baseURL)}
aria-label={t('settings.providers.page.custom.field.baseURL.label')}
/>
{err.baseURL ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.baseURL}</p> : null}
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.apiKey.label')}
info={
isEdit && allowExistingAuth
? t('settings.providers.page.custom.field.apiKey.editInfo')
: t('settings.providers.page.custom.field.apiKey.info')
}
>
<Input
type="password"
value={form.apiKey}
onChange={(event) => setField('apiKey', event.target.value)}
placeholder={
isEdit && allowExistingAuth
? t('settings.providers.page.custom.field.apiKey.editPlaceholder')
: t('settings.providers.page.custom.field.apiKey.placeholder')
}
className="h-8 rounded-md px-3 font-mono text-xs"
aria-invalid={Boolean(err.apiKey)}
aria-label={t('settings.providers.page.custom.field.apiKey.label')}
/>
{err.apiKey ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.apiKey}</p> : null}
</SettingsStackedField>
</SettingsSection>
<SettingsSection
title={t('settings.providers.page.custom.models.title')}
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
>
{form.models.map((model, index) => (
<div key={model.row} className={`${SETTINGS_CONTROL_CLUSTER_CLASS} space-y-2`}>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-2">
<div>
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.providers.page.custom.models.idLabel')}
</label>
<Input
value={model.id}
onChange={(event) => setModel(index, 'id', event.target.value)}
placeholder={t('settings.providers.page.custom.models.idPlaceholder')}
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
aria-label={t('settings.providers.page.custom.models.idLabel')}
/>
{modelErrors[index]?.id ? (
<p className="mt-1 typography-meta text-[var(--status-error)]">{modelErrors[index]?.id}</p>
) : null}
</div>
<div>
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.providers.page.custom.models.nameLabel')}
</label>
<Input
value={model.name}
onChange={(event) => setModel(index, 'name', event.target.value)}
placeholder={t('settings.providers.page.custom.models.namePlaceholder')}
className="mt-1 h-8 rounded-md px-3"
aria-label={t('settings.providers.page.custom.models.nameLabel')}
/>
{modelErrors[index]?.name ? (
<p className="mt-1 typography-meta text-[var(--status-error)]">{modelErrors[index]?.name}</p>
) : null}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className={SETTINGS_ICON_BUTTON_CLASS}
disabled={form.models.length <= 1}
onClick={() => {
if (form.models.length <= 1) return;
setForm((prev) => ({
...prev,
models: prev.models.filter((_, rowIndex) => rowIndex !== index),
}));
setModelErrors((prev) => prev.filter((_, rowIndex) => rowIndex !== index));
}}
aria-label={t('settings.providers.page.custom.models.remove')}
>
<Icon name="delete-bin" className="size-4" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
setForm((prev) => ({ ...prev, models: [...prev.models, createModelRow()] }));
setModelErrors((prev) => [...prev, {}]);
}}
>
{t('settings.providers.page.custom.models.add')}
</Button>
</SettingsSection>
<SettingsSection
title={t('settings.providers.page.custom.headers.title')}
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
>
<p className={SETTINGS_HELPER_CLASS}>{t('settings.providers.page.custom.headers.description')}</p>
{form.headers.map((header, index) => (
<div key={header.row} className={`${SETTINGS_CONTROL_CLUSTER_CLASS} space-y-2`}>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-2">
<div>
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.providers.page.custom.headers.keyLabel')}
</label>
<Input
value={header.key}
onChange={(event) => setHeader(index, 'key', event.target.value)}
placeholder={t('settings.providers.page.custom.headers.keyPlaceholder')}
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
aria-label={t('settings.providers.page.custom.headers.keyLabel')}
/>
{headerErrors[index]?.key ? (
<p className="mt-1 typography-meta text-[var(--status-error)]">{headerErrors[index]?.key}</p>
) : null}
</div>
<div>
<label className={SETTINGS_FIELD_LABEL_CLASS}>
{t('settings.providers.page.custom.headers.valueLabel')}
</label>
<Input
value={header.value}
onChange={(event) => setHeader(index, 'value', event.target.value)}
placeholder={t('settings.providers.page.custom.headers.valuePlaceholder')}
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
aria-label={t('settings.providers.page.custom.headers.valueLabel')}
/>
{headerErrors[index]?.value ? (
<p className="mt-1 typography-meta text-[var(--status-error)]">{headerErrors[index]?.value}</p>
) : null}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className={SETTINGS_ICON_BUTTON_CLASS}
disabled={form.headers.length <= 1}
onClick={() => {
if (form.headers.length <= 1) return;
setForm((prev) => ({
...prev,
headers: prev.headers.filter((_, rowIndex) => rowIndex !== index),
}));
setHeaderErrors((prev) => prev.filter((_, rowIndex) => rowIndex !== index));
}}
aria-label={t('settings.providers.page.custom.headers.remove')}
>
<Icon name="delete-bin" className="size-4" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
setForm((prev) => ({ ...prev, headers: [...prev.headers, createHeaderRow()] }));
setHeaderErrors((prev) => [...prev, {}]);
}}
>
{t('settings.providers.page.custom.headers.add')}
</Button>
</SettingsSection>
<div className="flex flex-wrap items-center gap-2 py-4">
{onCancel ? (
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={onCancel} disabled={busy}>
{t('settings.providers.page.custom.actions.back')}
</Button>
) : null}
{onDisconnect ? (
<Button
type="button"
variant="destructive"
size="xs"
className="!font-normal"
onClick={() => void onDisconnect()}
disabled={busy}
>
{t('settings.providers.page.actions.disconnect')}
</Button>
) : null}
<Button type="submit" size="xs" className="!font-normal" disabled={busy}>
{busy
? t('settings.providers.page.actions.saving')
: isEdit
? t('settings.providers.page.custom.actions.update')
: t('settings.providers.page.custom.actions.save')}
</Button>
</div>
</form>
);
};
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test';
import { shouldLoadAvailableProviders, shouldLoadProviderAuthMethods } from './providerAvailability';
import { listOAuthMethods, normalizeAuthType } from './providerAuthMethods';
describe('ProvidersPage available provider loading', () => {
test('loads available providers only in add-provider mode', () => {
@@ -16,3 +17,27 @@ describe('ProvidersPage auth method loading', () => {
expect(shouldLoadProviderAuthMethods(true, true)).toBe(true);
});
});
describe('ProvidersPage OAuth method indexes', () => {
test('preserves the original provider.auth() index after filtering', () => {
const methods = listOAuthMethods([
{ type: 'api' },
{ type: 'oauth', label: 'Browser' },
]);
expect(methods).toEqual([{ method: { type: 'oauth', label: 'Browser' }, methodIndex: 1 }]);
});
test('keeps multiple OAuth indexes relative to the full methods array', () => {
const methods = listOAuthMethods([
{ type: 'oauth', label: 'First' },
{ type: 'api' },
{ type: 'oauth', label: 'Second' },
]);
expect(methods.map((entry) => entry.methodIndex)).toEqual([0, 2]);
});
test('detects oauth from labels when type is missing', () => {
expect(normalizeAuthType({ label: 'Sign in with OAuth' })).toBe('oauth');
expect(normalizeAuthType({ name: 'API Key' })).toBe('api');
});
});
@@ -26,6 +26,19 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { opencodeClient } from '@/lib/opencode/client';
import { shouldLoadAvailableProviders, shouldLoadProviderAuthMethods } from './providerAvailability';
import { listOAuthMethods } from './providerAuthMethods';
import { CustomProviderForm } from './CustomProviderForm';
import {
buildAuthSetRequest,
buildProviderUpsertRequest,
CUSTOM_PROVIDER_ID,
isConfigDefinedCustomProvider,
providerToCustomFormState,
resolveProviderConfigScope,
type CustomProviderFormState,
type CustomProviderPersistPlan,
type ProviderConfigScope,
} from './custom-provider-form';
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
notation: 'compact',
@@ -77,21 +90,6 @@ interface ProviderSources {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const normalizeAuthType = (method: AuthMethod) => {
const raw = typeof method.type === 'string' ? method.type : '';
const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase();
const merged = `${raw} ${label}`.toLowerCase();
if (merged.includes('oauth')) return 'oauth';
if (merged.includes('api')) return 'api';
return raw.toLowerCase();
};
/** OAuth methods with the original provider.auth() method index OpenCode expects. */
const listOAuthMethods = (methods: AuthMethod[]): Array<{ method: AuthMethod; methodIndex: number }> =>
methods
.map((method, methodIndex) => ({ method, methodIndex }))
.filter(({ method }) => normalizeAuthType(method) === 'oauth');
const parseAuthPayload = (payload: unknown): Record<string, AuthMethod[]> => {
if (!isRecord(payload)) {
return {};
@@ -178,7 +176,20 @@ export const ProvidersPage: React.FC = () => {
const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false);
const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({});
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
const [editingCustomProviderId, setEditingCustomProviderId] = React.useState<string | null>(null);
const [editingCustomFormInitial, setEditingCustomFormInitial] = React.useState<CustomProviderFormState | null>(null);
const [editingCustomScope, setEditingCustomScope] = React.useState<ProviderConfigScope | null>(null);
const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState<string | null>(null);
const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(null);
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
const loadAuthMethods = shouldLoadProviderAuthMethods(isAddMode, showAuthPanel);
const isCustomCreateMode = isAddMode && candidateProviderId === CUSTOM_PROVIDER_ID;
const isCustomEditMode = Boolean(
editingCustomProviderId
&& selectedProviderId
&& editingCustomProviderId === selectedProviderId
&& !isAddMode,
);
React.useEffect(() => {
if (!selectedProviderId && providers.length > 0) {
@@ -187,13 +198,13 @@ export const ProvidersPage: React.FC = () => {
}, [providers, selectedProviderId, setSelectedProvider]);
React.useEffect(() => {
if (!shouldLoadProviderAuthMethods(isAddMode, showAuthPanel)) {
if (!loadAuthMethods) {
return;
}
let isMounted = true;
const loadAuthMethods = async () => {
const fetchAuthMethods = async () => {
setAuthLoading(true);
try {
const result = await opencodeClient.getSdkClient().provider.auth();
@@ -213,12 +224,12 @@ export const ProvidersPage: React.FC = () => {
}
};
loadAuthMethods();
void fetchAuthMethods();
return () => {
isMounted = false;
};
}, [isAddMode, showAuthPanel, t]);
}, [loadAuthMethods, t]);
React.useEffect(() => {
if (!shouldLoadAvailableProviders(isAddMode)) {
@@ -277,7 +288,11 @@ export const ProvidersPage: React.FC = () => {
return;
}
if (candidateProviderId && !unconnectedProviders.some((provider) => provider.id === candidateProviderId)) {
if (
candidateProviderId
&& candidateProviderId !== CUSTOM_PROVIDER_ID
&& !unconnectedProviders.some((provider) => provider.id === candidateProviderId)
) {
setCandidateProviderId('');
}
}, [selectedProviderId, candidateProviderId, unconnectedProviders]);
@@ -285,11 +300,21 @@ export const ProvidersPage: React.FC = () => {
React.useEffect(() => {
if (selectedProviderId === ADD_PROVIDER_ID) {
setShowAuthPanel(true);
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
return;
}
setShowAuthPanel(false);
}, [selectedProviderId, t]);
if (editingCustomProviderId && editingCustomProviderId !== selectedProviderId) {
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
}
}, [selectedProviderId, editingCustomProviderId]);
React.useEffect(() => {
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
@@ -367,6 +392,68 @@ export const ProvidersPage: React.FC = () => {
}
};
const handleSaveCustomProvider = async (plan: CustomProviderPersistPlan) => {
const busyKey = `custom:${plan.providerID}`;
setAuthBusyKey(busyKey);
setLastCustomPersistId(plan.providerID);
setCustomAuthFailureHint(null);
try {
// Auth first so a failed key write cannot leave an orphan config that
// blocks create validation, and so PUT can pass hasStoredAuth for literal keys.
const authRequest = buildAuthSetRequest(plan);
if (authRequest) {
const authResult = await opencodeClient.getSdkClient().auth.set(authRequest);
if (authResult.error) {
throw new Error(t('settings.providers.page.toast.apiKeySaveFailed'));
}
}
const upsertBody = buildProviderUpsertRequest(plan, {
// Create defaults to user. Edit must rewrite the winning config layer
// (custom > project > user) so project/custom providers are not copied
// into a global user override.
scope: editingCustomProviderId
? (editingCustomScope ?? resolveProviderConfigScope(providerSources[editingCustomProviderId]))
: 'user',
});
const response = await runtimeFetch('/api/provider', {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(upsertBody),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
if (authRequest) {
setCustomAuthFailureHint(t('settings.providers.page.custom.authFailure.configAfterAuth'));
}
throw new Error(payload?.error || t('settings.providers.page.toast.customProviderSaveFailed'));
}
toast.success(t('settings.providers.page.toast.customProviderSaved', { provider: plan.name }));
setCandidateProviderId('');
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
noteDeferredRestartFromPayload(payload, 'providers', { id: plan.providerID });
setSelectedProvider(plan.providerID);
} catch (error) {
console.error('Failed to save custom provider:', error);
toast.error(
error instanceof Error && error.message
? error.message
: t('settings.providers.page.toast.customProviderSaveFailed'),
);
} finally {
setAuthBusyKey(null);
}
};
const handleOAuthStart = async (providerId: string, methodIndex: number) => {
const busyKey = `oauth:${providerId}:${methodIndex}`;
setAuthBusyKey(busyKey);
@@ -507,6 +594,19 @@ export const ProvidersPage: React.FC = () => {
}
};
const handleDisconnectCustomProvider = async (providerId: string) => {
if (!providerId) {
return;
}
await handleDisconnectProvider(providerId);
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
setCandidateProviderId('');
};
if (!isAddMode && providers.length === 0) {
return (
<div className="flex h-full items-center justify-center">
@@ -536,8 +636,6 @@ export const ProvidersPage: React.FC = () => {
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.state.loading')}</p>
) : availableError ? (
<p className="typography-meta text-muted-foreground">{availableError}</p>
) : unconnectedProviders.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.connect.allProvidersConnected')}</p>
) : (
<DropdownMenu open={providerDropdownOpen} onOpenChange={(open) => {
setProviderDropdownOpen(open);
@@ -549,11 +647,15 @@ export const ProvidersPage: React.FC = () => {
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
>
<span className="flex items-center gap-2 min-w-0">
{candidateProviderId ? <ProviderLogo providerId={candidateProviderId} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
{candidateProviderId && candidateProviderId !== CUSTOM_PROVIDER_ID ? (
<ProviderLogo providerId={candidateProviderId} className="h-3.5 w-3.5 flex-shrink-0" />
) : null}
<span className={cn("truncate typography-ui-label font-normal", candidateProviderId ? "text-foreground" : "text-muted-foreground")}>
{candidateProviderId
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
: t('settings.providers.page.connect.selectProviderPlaceholder')}
{candidateProviderId === CUSTOM_PROVIDER_ID
? t('settings.providers.page.custom.optionLabel')
: candidateProviderId
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
: t('settings.providers.page.connect.selectProviderPlaceholder')}
</span>
</span>
<Icon name="arrow-down-s" className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
@@ -581,32 +683,60 @@ export const ProvidersPage: React.FC = () => {
</div>
<ScrollableOverlay outerClassName="max-h-[240px]" className="p-1">
{(() => {
const query = providerSearchQuery.toLowerCase();
const customLabel = t('settings.providers.page.custom.optionLabel');
const customMatches = !query
|| customLabel.toLowerCase().includes(query)
|| 'other'.includes(query)
|| 'custom'.includes(query);
const filtered = unconnectedProviders.filter(p => {
const query = providerSearchQuery.toLowerCase();
return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
});
if (filtered.length === 0) {
if (filtered.length === 0 && !customMatches) {
return <p className="py-4 text-center typography-meta text-muted-foreground">{t('settings.providers.page.connect.noProvidersFound')}</p>;
}
return filtered.map((provider) => (
<DropdownMenuItem
key={provider.id}
onSelect={() => {
setCandidateProviderId(provider.id);
setProviderDropdownOpen(false);
setProviderSearchQuery('');
}}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2 min-w-0">
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{provider.name || provider.id}</span>
</span>
{candidateProviderId === provider.id && (
<Icon name="check" className="h-4 w-4 text-[var(--primary-base)]" />
)}
</DropdownMenuItem>
));
return (
<>
{filtered.map((provider) => (
<DropdownMenuItem
key={provider.id}
onSelect={() => {
setCandidateProviderId(provider.id);
setProviderDropdownOpen(false);
setProviderSearchQuery('');
}}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2 min-w-0">
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{provider.name || provider.id}</span>
</span>
{candidateProviderId === provider.id && (
<Icon name="check" className="h-4 w-4 text-[var(--primary-base)]" />
)}
</DropdownMenuItem>
))}
{customMatches ? (
<DropdownMenuItem
key={CUSTOM_PROVIDER_ID}
onSelect={() => {
setCandidateProviderId(CUSTOM_PROVIDER_ID);
setProviderDropdownOpen(false);
setProviderSearchQuery('');
}}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2 min-w-0">
<Icon name="add" className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{customLabel}</span>
</span>
{candidateProviderId === CUSTOM_PROVIDER_ID && (
<Icon name="check" className="h-4 w-4 text-[var(--primary-base)]" />
)}
</DropdownMenuItem>
) : null}
</>
);
})()}
</ScrollableOverlay>
</DropdownMenuContent>
@@ -615,7 +745,25 @@ export const ProvidersPage: React.FC = () => {
</div>
</SettingsSection>
{candidateProviderId && (
{isCustomCreateMode ? (
<CustomProviderForm
mode="create"
existingProviderIDs={connectedProviderIds}
busy={authBusyKey?.startsWith('custom:') ?? false}
authFailureHint={customAuthFailureHint}
onCancel={() => {
setCandidateProviderId('');
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
}}
onDisconnect={
customAuthFailureHint && lastCustomPersistId
? () => void handleDisconnectCustomProvider(lastCustomPersistId)
: undefined
}
onSubmit={handleSaveCustomProvider}
/>
) : candidateProviderId ? (
<SettingsSection
title={t('settings.providers.page.auth.title')}
settingsItem="providers.auth"
@@ -747,7 +895,7 @@ export const ProvidersPage: React.FC = () => {
</>
)}
</SettingsSection>
)}
) : null}
</SettingsPageLayout>
);
}
@@ -767,6 +915,16 @@ export const ProvidersPage: React.FC = () => {
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
const oauthAuthMethods = listOAuthMethods(providerAuthMethods);
const sourcesLoaded = Boolean(selectedSources);
const isEditableCustomProvider = sourcesLoaded
&& isConfigDefinedCustomProvider(selectedProvider, selectedSources);
const providerEnv = Array.isArray(selectedProvider.env)
? selectedProvider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
: [];
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
const hasEnvCredentials = providerEnv.length > 0;
const hasCredentials = hasStoredAuth || hasEnvCredentials;
const authStatusIncomplete = isEditableCustomProvider && !hasCredentials;
const filteredModels = providerModels.filter((model) => {
const name = typeof model?.name === 'string' ? model.name : '';
@@ -776,6 +934,35 @@ export const ProvidersPage: React.FC = () => {
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
});
if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) {
return (
<SettingsPageLayout
title={selectedProvider.name || selectedProvider.id}
titleLeading={<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5 shrink-0" />}
description={<span className="font-mono typography-settings-description text-muted-foreground">{selectedProvider.id}</span>}
showSaveStatus={false}
>
<CustomProviderForm
mode="edit"
existingProviderIDs={connectedProviderIds}
initialValues={editingCustomFormInitial}
allowExistingAuth={hasCredentials || !sourcesLoaded}
busy={authBusyKey?.startsWith('custom:') ?? false}
authFailureHint={customAuthFailureHint}
onCancel={() => {
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
}}
onDisconnect={() => void handleDisconnectCustomProvider(selectedProvider.id)}
onSubmit={handleSaveCustomProvider}
/>
</SettingsPageLayout>
);
}
return (
<SettingsPageLayout
title={selectedProvider.name || selectedProvider.id}
@@ -787,23 +974,48 @@ export const ProvidersPage: React.FC = () => {
title={t('settings.providers.page.auth.title')}
divider={false}
headerAction={(
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => setShowAuthPanel((prev) => !prev)}
>
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
</Button>
<div className="flex items-center gap-1">
{isEditableCustomProvider ? (
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
setCustomAuthFailureHint(null);
setEditingCustomFormInitial(providerToCustomFormState(selectedProvider));
setEditingCustomScope(resolveProviderConfigScope(selectedSources));
setEditingCustomProviderId(selectedProvider.id);
}}
>
{t('settings.providers.page.actions.edit')}
</Button>
) : null}
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => setShowAuthPanel((prev) => !prev)}
>
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
</Button>
</div>
)}
settingsItem="providers.auth"
>
{!showAuthPanel ? (
<div className="flex items-center gap-1.5 py-1.5">
<Icon name="check" className="w-4 h-4 text-[var(--status-success)] shrink-0" />
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
<SettingsInfoHint>{t('settings.providers.page.auth.useReconnectHint')}</SettingsInfoHint>
</div>
authStatusIncomplete ? (
<div className="flex items-center gap-1.5 py-1.5">
<Icon name="alert" className="w-4 h-4 text-[var(--status-warning)] shrink-0" />
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.incomplete')}</span>
<SettingsInfoHint>{t('settings.providers.page.auth.incompleteHint')}</SettingsInfoHint>
</div>
) : (
<div className="flex items-center gap-1.5 py-1.5">
<Icon name="check" className="w-4 h-4 text-[var(--status-success)] shrink-0" />
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
<SettingsInfoHint>{t('settings.providers.page.auth.useReconnectHint')}</SettingsInfoHint>
</div>
)
) : authLoading ? (
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
) : (
@@ -0,0 +1,350 @@
import { describe, expect, test } from 'bun:test';
import {
buildAuthSetRequest,
buildProviderUpsertRequest,
isConfigDefinedCustomProvider,
isCustomOpenAICompatibleProvider,
providerToCustomFormState,
resolveProviderConfigScope,
validateCustomProvider,
type CustomProviderConfig,
type CustomProviderFormState,
} from './custom-provider-form';
const t = (key: string) => key;
const baseForm = (overrides: Partial<CustomProviderFormState> = {}): CustomProviderFormState => ({
providerID: 'custom-provider',
name: 'Custom Provider',
baseURL: 'https://api.example.com/v1',
apiKey: 'sk-test',
models: [{ row: 'm0', id: 'model-a', name: 'Model A' }],
headers: [{ row: 'h0', key: '', value: '' }],
...overrides,
});
/** Mirrors server upsert semantics for request-construction tests. */
function mergeProviderConfig(
existing: Record<string, unknown>,
providerID: string,
config: CustomProviderConfig,
): Record<string, unknown> {
const providerSection = (
typeof existing.provider === 'object' && existing.provider !== null && !Array.isArray(existing.provider)
? { ...(existing.provider as Record<string, unknown>) }
: {}
);
providerSection[providerID] = config;
const next: Record<string, unknown> = {
...existing,
provider: providerSection,
};
if (Array.isArray(existing.disabled_providers)) {
next.disabled_providers = existing.disabled_providers.filter((entry) => entry !== providerID);
}
return next;
}
describe('validateCustomProvider', () => {
test('builds trimmed config and auth payloads', () => {
const result = validateCustomProvider({
form: baseForm({
providerID: ' custom-provider ',
name: ' Custom Provider ',
baseURL: ' https://api.example.com/v1 ',
apiKey: ' sk-secret ',
models: [{ row: 'm0', id: ' model-a ', name: ' Model A ' }],
headers: [
{ row: 'h0', key: ' X-Test ', value: ' enabled ' },
{ row: 'h1', key: '', value: '' },
],
}),
t,
existingProviderIDs: new Set(),
});
expect(result.result).toEqual({
providerID: 'custom-provider',
name: 'Custom Provider',
apiKey: 'sk-secret',
config: {
npm: '@ai-sdk/openai-compatible',
name: 'Custom Provider',
options: {
baseURL: 'https://api.example.com/v1',
headers: {
'X-Test': 'enabled',
},
},
models: {
'model-a': { name: 'Model A' },
},
},
});
});
test('supports {env:VAR} credentials without writing an auth key', () => {
const result = validateCustomProvider({
form: baseForm({
apiKey: '{env: CUSTOM_PROVIDER_KEY}',
}),
t,
existingProviderIDs: new Set(),
});
expect(result.result?.apiKey).toEqual(undefined);
expect(result.result?.config.env).toEqual(['CUSTOM_PROVIDER_KEY']);
});
test('rejects missing credentials', () => {
const result = validateCustomProvider({
form: baseForm({ apiKey: ' ' }),
t,
existingProviderIDs: new Set(),
});
expect(result.result).toEqual(undefined);
expect(result.err.apiKey).toBe('settings.providers.page.custom.error.apiKey.required');
});
test('allows empty api key when editing with existing auth', () => {
const result = validateCustomProvider({
form: baseForm({ apiKey: '' }),
t,
existingProviderIDs: new Set(['custom-provider']),
editingProviderID: 'custom-provider',
allowExistingAuth: true,
});
expect(result.result?.providerID).toBe('custom-provider');
expect(result.err.apiKey).toEqual(undefined);
expect(result.result?.apiKey).toEqual(undefined);
});
test('rejects invalid provider id, base URL, and duplicate rows', () => {
const result = validateCustomProvider({
form: baseForm({
providerID: 'Bad ID',
baseURL: 'ftp://example.com',
models: [
{ row: 'm0', id: 'model-a', name: 'Model A' },
{ row: 'm1', id: 'model-a', name: 'Model A 2' },
],
headers: [
{ row: 'h0', key: 'Authorization', value: 'one' },
{ row: 'h1', key: 'authorization', value: 'two' },
],
}),
t,
existingProviderIDs: new Set(),
});
expect(result.result).toEqual(undefined);
expect(result.err.providerID).toBe('settings.providers.page.custom.error.providerID.format');
expect(result.err.baseURL).toBe('settings.providers.page.custom.error.baseURL.format');
expect(result.models[1]).toEqual({
id: 'settings.providers.page.custom.error.duplicate',
name: undefined,
});
expect(result.headers[1]).toEqual({
key: 'settings.providers.page.custom.error.duplicate',
value: undefined,
});
});
test('allows reconnecting a disabled provider id', () => {
const result = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(['custom-provider']),
disabledProviders: ['custom-provider'],
});
expect(result.result?.providerID).toBe('custom-provider');
expect(result.err.providerID).toEqual(undefined);
});
test('rejects an already-connected provider id on create', () => {
const result = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(['custom-provider']),
});
expect(result.result).toEqual(undefined);
expect(result.err.providerID).toBe('settings.providers.page.custom.error.providerID.exists');
});
test('allows updating the same provider id while editing', () => {
const result = validateCustomProvider({
form: baseForm({ apiKey: 'sk-updated' }),
t,
existingProviderIDs: new Set(['custom-provider']),
editingProviderID: 'custom-provider',
});
expect(result.result?.providerID).toBe('custom-provider');
expect(result.err.providerID).toEqual(undefined);
});
});
describe('request construction', () => {
test('builds auth.set and provider upsert requests', () => {
const validated = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(),
});
const plan = validated.result!;
expect(buildAuthSetRequest(plan)).toEqual({
providerID: 'custom-provider',
auth: { type: 'api', key: 'sk-test' },
});
expect(buildProviderUpsertRequest(plan)).toEqual({
providerID: 'custom-provider',
config: plan.config,
scope: 'user',
});
});
test('includes explicit project/custom scope on upsert requests', () => {
const validated = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(),
});
const plan = validated.result!;
expect(buildProviderUpsertRequest(plan, { scope: 'project' }).scope).toBe('project');
expect(buildProviderUpsertRequest(plan, { scope: 'custom' }).scope).toBe('custom');
});
test('omits auth.set when using env credentials', () => {
const validated = validateCustomProvider({
form: baseForm({ apiKey: '{env:MY_KEY}' }),
t,
existingProviderIDs: new Set(),
});
expect(buildAuthSetRequest(validated.result!)).toBeNull();
});
});
describe('mergeProviderConfig persistence shape', () => {
test('merges provider block and clears disabled_providers entry', () => {
const validated = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(),
});
const plan = validated.result!;
const next = mergeProviderConfig(
{
model: 'openai/gpt-4o',
provider: {
openai: { name: 'OpenAI' },
},
disabled_providers: ['custom-provider', 'other'],
},
plan.providerID,
plan.config,
);
expect(next).toEqual({
model: 'openai/gpt-4o',
provider: {
openai: { name: 'OpenAI' },
'custom-provider': plan.config,
},
disabled_providers: ['other'],
});
});
test('creates provider section when missing', () => {
const validated = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(),
});
const plan = validated.result!;
const next = mergeProviderConfig({}, plan.providerID, plan.config);
expect(next.provider).toEqual({
'custom-provider': plan.config,
});
});
});
describe('provider edit helpers', () => {
test('detects openai-compatible custom providers and prefills form state', () => {
expect(isCustomOpenAICompatibleProvider({
id: 'campus-llm',
options: { baseURL: 'https://llm.example.edu/v1' },
models: [],
})).toBe(true);
const state = providerToCustomFormState({
id: 'campus-llm',
name: 'Campus LLM',
env: ['CAMPUS_KEY'],
options: {
baseURL: 'https://llm.example.edu/v1',
headers: { 'X-Campus': '1' },
},
models: [{ id: 'fast', name: 'Fast' }],
});
expect(state.providerID).toBe('campus-llm');
expect(state.name).toBe('Campus LLM');
expect(state.baseURL).toBe('https://llm.example.edu/v1');
expect(state.apiKey).toBe('{env:CAMPUS_KEY}');
expect(state.models[0]).toEqual({ row: state.models[0].row, id: 'fast', name: 'Fast' });
expect(state.headers[0]).toEqual({ row: state.headers[0].row, key: 'X-Campus', value: '1' });
});
test('requires a config-layer source before treating a provider as editable custom', () => {
const catalogLike = {
id: 'openai',
options: { baseURL: 'https://api.openai.com/v1' },
models: [{ id: 'gpt-4o', name: 'GPT-4o', api: { npm: '@ai-sdk/openai-compatible' } }],
};
expect(isCustomOpenAICompatibleProvider(catalogLike)).toBe(true);
expect(isConfigDefinedCustomProvider(catalogLike, undefined)).toBe(false);
expect(isConfigDefinedCustomProvider(catalogLike, {
user: { exists: false },
project: { exists: false },
custom: { exists: false },
})).toBe(false);
expect(isConfigDefinedCustomProvider(catalogLike, {
user: { exists: true },
project: { exists: false },
})).toBe(true);
});
test('resolveProviderConfigScope follows custom > project > user precedence', () => {
expect(resolveProviderConfigScope(undefined)).toBe('user');
expect(resolveProviderConfigScope({
user: { exists: true },
project: { exists: false },
custom: { exists: false },
})).toBe('user');
expect(resolveProviderConfigScope({
user: { exists: true },
project: { exists: true },
custom: { exists: false },
})).toBe('project');
expect(resolveProviderConfigScope({
user: { exists: true },
project: { exists: true },
custom: { exists: true },
})).toBe('custom');
expect(resolveProviderConfigScope({
user: { exists: false },
project: { exists: false },
custom: { exists: true },
})).toBe('custom');
});
});
@@ -0,0 +1,410 @@
/**
* Custom / Other OpenAI-compatible provider form helpers.
* Mirrors OpenCode web UI validation and request construction so a provider
* can be defined from Settings without code changes.
*/
export const CUSTOM_PROVIDER_NPM = '@ai-sdk/openai-compatible';
export const CUSTOM_PROVIDER_ID = '__custom_provider__';
export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
export const BASE_URL_PATTERN = /^https?:\/\//;
export const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
export type CustomProviderTranslator = (
key: string,
vars?: Record<string, string | number | boolean>,
) => string;
export type ModelRow = {
row: string;
id: string;
name: string;
};
export type HeaderRow = {
row: string;
key: string;
value: string;
};
export type CustomProviderFormState = {
providerID: string;
name: string;
baseURL: string;
apiKey: string;
models: ModelRow[];
headers: HeaderRow[];
};
export type FieldErrors = {
providerID?: string;
name?: string;
baseURL?: string;
apiKey?: string;
};
export type ModelFieldErrors = {
id?: string;
name?: string;
};
export type HeaderFieldErrors = {
key?: string;
value?: string;
};
export type CustomProviderConfig = {
npm: typeof CUSTOM_PROVIDER_NPM;
name: string;
env?: string[];
options: {
baseURL: string;
headers?: Record<string, string>;
};
models: Record<string, { name: string }>;
};
export type CustomProviderPersistPlan = {
providerID: string;
name: string;
/** Literal API key to send via auth.set; omitted when using {env:VAR} or empty. */
apiKey?: string;
config: CustomProviderConfig;
};
export type ValidateCustomProviderInput = {
form: CustomProviderFormState;
t: CustomProviderTranslator;
existingProviderIDs: ReadonlySet<string>;
disabledProviders?: readonly string[];
/** When editing this provider id, treat it as an allowed update target. */
editingProviderID?: string;
/**
* When true, empty apiKey is allowed because auth.json already has a credential
* (edit path). Still requires env or key when false.
*/
allowExistingAuth?: boolean;
};
export type ValidateCustomProviderResult = {
err: FieldErrors;
models: ModelFieldErrors[];
headers: HeaderFieldErrors[];
result?: CustomProviderPersistPlan;
};
export type ProviderLikeForCustomForm = {
id: string;
name?: string;
env?: string[];
options?: Record<string, unknown> | null;
models?: Array<{ id?: string; name?: string; api?: { npm?: string } }> | Record<string, unknown>;
};
let rowCounter = 0;
const nextRow = (): string => `row-${rowCounter++}`;
export const createModelRow = (): ModelRow => ({
row: nextRow(),
id: '',
name: '',
});
export const createHeaderRow = (): HeaderRow => ({
row: nextRow(),
key: '',
value: '',
});
export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({
providerID: '',
name: '',
baseURL: '',
apiKey: '',
models: [createModelRow()],
headers: [createHeaderRow()],
});
export function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
const trimmed = apiKey.trim();
if (!trimmed) {
return {};
}
const envMatch = trimmed.match(ENV_KEY_PATTERN);
const env = envMatch?.[1]?.trim();
if (env) {
return { env };
}
return { key: trimmed };
}
export function isCustomOpenAICompatibleProvider(provider: ProviderLikeForCustomForm): boolean {
const options = provider.options && typeof provider.options === 'object' ? provider.options : null;
const baseURL = typeof options?.baseURL === 'string' ? options.baseURL.trim() : '';
if (baseURL && BASE_URL_PATTERN.test(baseURL)) {
return true;
}
const models = Array.isArray(provider.models)
? provider.models
: (provider.models && typeof provider.models === 'object'
? Object.values(provider.models)
: []);
return models.some((model) => {
if (!model || typeof model !== 'object') {
return false;
}
const api = 'api' in model && model.api && typeof model.api === 'object'
? model.api as { npm?: unknown }
: null;
return typeof api?.npm === 'string' && api.npm === CUSTOM_PROVIDER_NPM;
});
}
export type ProviderConfigSourcesLike = {
user?: { exists?: boolean };
project?: { exists?: boolean };
custom?: { exists?: boolean };
};
export type ProviderConfigScope = 'user' | 'project' | 'custom';
/**
* True when a provider both looks OpenAI-compatible-custom and is defined in a
* user/project/custom OpenCode config layer. Catalog-only providers often share
* the same npm/baseURL signals and must not get Edit / config overrides.
*/
export function isConfigDefinedCustomProvider(
provider: ProviderLikeForCustomForm,
sources: ProviderConfigSourcesLike | null | undefined,
): boolean {
if (!sources) {
return false;
}
const inConfigLayer = Boolean(
sources.user?.exists || sources.project?.exists || sources.custom?.exists,
);
return inConfigLayer && isCustomOpenAICompatibleProvider(provider);
}
/**
* Effective writable config layer for a provider, matching OpenCode merge
* precedence: custom > project > user.
*/
export function resolveProviderConfigScope(
sources: ProviderConfigSourcesLike | null | undefined,
): ProviderConfigScope {
if (sources?.custom?.exists) {
return 'custom';
}
if (sources?.project?.exists) {
return 'project';
}
return 'user';
}
export function providerToCustomFormState(provider: ProviderLikeForCustomForm): CustomProviderFormState {
const options = provider.options && typeof provider.options === 'object' ? provider.options : {};
const baseURL = typeof options.baseURL === 'string' ? options.baseURL : '';
const headersRaw = options.headers && typeof options.headers === 'object' && !Array.isArray(options.headers)
? options.headers as Record<string, unknown>
: {};
const headerRows = Object.entries(headersRaw)
.filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string')
.map(([key, value]) => ({ row: nextRow(), key, value }));
const modelEntries = Array.isArray(provider.models)
? provider.models
: (provider.models && typeof provider.models === 'object'
? Object.entries(provider.models).map(([id, value]) => ({
id,
name: value && typeof value === 'object' && 'name' in value && typeof (value as { name?: unknown }).name === 'string'
? (value as { name: string }).name
: id,
}))
: []);
const models = modelEntries.length > 0
? modelEntries.map((model) => ({
row: nextRow(),
id: typeof model?.id === 'string' ? model.id : '',
name: typeof model?.name === 'string' ? model.name : (typeof model?.id === 'string' ? model.id : ''),
}))
: [createModelRow()];
const envName = Array.isArray(provider.env)
? provider.env.find((entry) => typeof entry === 'string' && entry.trim().length > 0)?.trim()
: undefined;
return {
providerID: provider.id,
name: typeof provider.name === 'string' && provider.name.trim() ? provider.name : provider.id,
baseURL,
apiKey: envName ? `{env:${envName}}` : '',
models,
headers: headerRows.length > 0 ? headerRows : [createHeaderRow()],
};
}
/**
* Validates form input and builds the auth + OpenCode provider config payloads.
*/
export function validateCustomProvider(input: ValidateCustomProviderInput): ValidateCustomProviderResult {
const providerID = input.form.providerID.trim();
const name = input.form.name.trim();
const baseURL = input.form.baseURL.trim();
const { env, key } = parseEnvApiKey(input.form.apiKey);
const disabledProviders = input.disabledProviders ?? [];
const editingProviderID = input.editingProviderID?.trim();
const idError = !providerID
? input.t('settings.providers.page.custom.error.providerID.required')
: !PROVIDER_ID_PATTERN.test(providerID)
? input.t('settings.providers.page.custom.error.providerID.format')
: undefined;
const nameError = !name
? input.t('settings.providers.page.custom.error.name.required')
: undefined;
const urlError = !baseURL
? input.t('settings.providers.page.custom.error.baseURL.required')
: !BASE_URL_PATTERN.test(baseURL)
? input.t('settings.providers.page.custom.error.baseURL.format')
: undefined;
const credentialsSatisfied = Boolean(env || key || (editingProviderID && input.allowExistingAuth && editingProviderID === providerID));
const apiKeyError = credentialsSatisfied
? undefined
: input.t('settings.providers.page.custom.error.apiKey.required');
const disabled = disabledProviders.includes(providerID);
const isSelfEdit = Boolean(editingProviderID && editingProviderID === providerID);
const existsError = idError || isSelfEdit
? undefined
: input.existingProviderIDs.has(providerID) && !disabled
? input.t('settings.providers.page.custom.error.providerID.exists')
: undefined;
const seenModels = new Set<string>();
const modelErrors = input.form.models.map((model) => {
const id = model.id.trim();
const modelIdError = !id
? input.t('settings.providers.page.custom.error.required')
: seenModels.has(id)
? input.t('settings.providers.page.custom.error.duplicate')
: (() => {
seenModels.add(id);
return undefined;
})();
const modelNameError = !model.name.trim()
? input.t('settings.providers.page.custom.error.required')
: undefined;
return { id: modelIdError, name: modelNameError };
});
const modelsValid = modelErrors.every((entry) => !entry.id && !entry.name);
const modelConfig = Object.fromEntries(
input.form.models.map((model) => [model.id.trim(), { name: model.name.trim() }]),
);
const seenHeaders = new Set<string>();
const headerErrors = input.form.headers.map((header) => {
const headerKey = header.key.trim();
const headerValue = header.value.trim();
if (!headerKey && !headerValue) {
return {};
}
const keyError = !headerKey
? input.t('settings.providers.page.custom.error.required')
: seenHeaders.has(headerKey.toLowerCase())
? input.t('settings.providers.page.custom.error.duplicate')
: (() => {
seenHeaders.add(headerKey.toLowerCase());
return undefined;
})();
const valueError = !headerValue
? input.t('settings.providers.page.custom.error.required')
: undefined;
return { key: keyError, value: valueError };
});
const headersValid = headerErrors.every((entry) => !entry.key && !entry.value);
const headerConfig = Object.fromEntries(
input.form.headers
.map((header) => ({ key: header.key.trim(), value: header.value.trim() }))
.filter((header) => header.key && header.value)
.map((header) => [header.key, header.value]),
);
const err: FieldErrors = {
providerID: idError ?? existsError,
name: nameError,
baseURL: urlError,
apiKey: apiKeyError,
};
const ok = !idError && !existsError && !nameError && !urlError && !apiKeyError && modelsValid && headersValid;
if (!ok) {
return { err, models: modelErrors, headers: headerErrors };
}
return {
err,
models: modelErrors,
headers: headerErrors,
result: {
providerID,
name,
apiKey: key,
config: {
npm: CUSTOM_PROVIDER_NPM,
name,
...(env ? { env: [env] } : {}),
options: {
baseURL,
...(Object.keys(headerConfig).length > 0 ? { headers: headerConfig } : {}),
},
models: modelConfig,
},
},
};
}
/**
* Builds the OpenCode auth.set request body when a literal API key is present.
*/
export function buildAuthSetRequest(plan: CustomProviderPersistPlan): {
providerID: string;
auth: { type: 'api'; key: string };
} | null {
if (!plan.apiKey) {
return null;
}
return {
providerID: plan.providerID,
auth: { type: 'api', key: plan.apiKey },
};
}
/**
* Builds the OpenChamber provider upsert request body (config persistence).
* `scope` selects the OpenCode config layer (user/project/custom). Create
* defaults to user; edit must pass the provider's effective existing layer.
*/
export function buildProviderUpsertRequest(
plan: CustomProviderPersistPlan,
options?: { scope?: ProviderConfigScope },
): {
providerID: string;
config: CustomProviderConfig;
scope: ProviderConfigScope;
} {
return {
providerID: plan.providerID,
config: plan.config,
scope: options?.scope ?? 'user',
};
}
@@ -0,0 +1,24 @@
export type ProviderAuthMethod = {
type?: string;
name?: string;
label?: string;
description?: string;
help?: string;
};
export const normalizeAuthType = (method: ProviderAuthMethod): string => {
const raw = typeof method.type === 'string' ? method.type : '';
const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase();
const merged = `${raw} ${label}`.toLowerCase();
if (merged.includes('oauth')) return 'oauth';
if (merged.includes('api')) return 'api';
return raw.toLowerCase();
};
/** OAuth methods with the original provider.auth() method index OpenCode expects. */
export const listOAuthMethods = (
methods: ProviderAuthMethod[],
): Array<{ method: ProviderAuthMethod; methodIndex: number }> =>
methods
.map((method, methodIndex) => ({ method, methodIndex }))
.filter(({ method }) => normalizeAuthType(method) === 'oauth');
@@ -35,6 +35,9 @@ interface SkillsSidebarProps {
const BUILT_IN_SKILL_LOCATION = '<built-in>';
const isBuiltInSkill = (skill: DiscoveredSkill | null | undefined): boolean => skill?.path === BUILT_IN_SKILL_LOCATION;
const isRenamableSkill = (skill: DiscoveredSkill | null | undefined): boolean => (
!!skill && !isBuiltInSkill(skill) && skill.renamable === true
);
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
@@ -49,16 +52,16 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
skills,
setSelectedSkill,
setSkillDraft,
createSkill,
deleteSkill,
renameSkill,
getSkillDetail,
} = useSkillsStore(useShallow((s) => ({
selectedSkillName: s.selectedSkillName,
skills: s.skills,
setSelectedSkill: s.setSelectedSkill,
setSkillDraft: s.setSkillDraft,
createSkill: s.createSkill,
deleteSkill: s.deleteSkill,
renameSkill: s.renameSkill,
getSkillDetail: s.getSkillDetail,
})));
@@ -140,14 +143,14 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
};
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
if (isBuiltInSkill(skill)) return;
if (!isRenamableSkill(skill)) return;
setRenameNewName(skill.name);
setRenameDialogSkill(skill);
};
const handleRenameSkill = async () => {
if (!renameDialogSkill) return;
if (isBuiltInSkill(renameDialogSkill)) {
if (!isRenamableSkill(renameDialogSkill)) {
setRenameDialogSkill(null);
return;
}
@@ -169,31 +172,11 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
return;
}
// Get full detail to copy
const detail = await getSkillDetail(renameDialogSkill.name);
if (!detail) {
toast.error(t('settings.skills.sidebar.toast.renameLoadFailed'));
setRenameDialogSkill(null);
return;
}
// Create new skill with new name
const success = await createSkill({
name: sanitizedName,
description: 'Renamed skill', // Will need proper description
scope: renameDialogSkill.scope,
source: renameDialogSkill.source,
});
// Rename in place on disk so SKILL.md body and supporting files are preserved.
const success = await renameSkill(renameDialogSkill.name, sanitizedName);
if (success) {
// Delete old skill
const deleteSuccess = await deleteSkill(renameDialogSkill.name);
if (deleteSuccess) {
toast.success(`Skill renamed to "${sanitizedName}"`);
setSelectedSkill(sanitizedName);
} else {
toast.error(t('settings.skills.sidebar.toast.removeOldAfterRenameFailed'));
}
toast.success(t('settings.skills.sidebar.toast.skillRenamed', { name: sanitizedName }));
setSelectedSkill(sanitizedName);
} else {
toast.error(t('settings.skills.sidebar.toast.renameFailed'));
}
@@ -463,13 +446,16 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
: t('settings.skills.sidebar.badge.opencode');
const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50';
const isBuiltIn = isBuiltInSkill(skill);
const canRename = isRenamableSkill(skill);
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
const renderMenuItems = (Item: React.ElementType) => (
<>
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</Item>
{canRename ? (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</Item>
) : null}
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDuplicate(); }}>
<Icon name="file-copy" className="h-4 w-4 mr-px" />
{t('settings.common.actions.duplicate')}
@@ -18,6 +18,42 @@ import type { TerminalChunk } from '@/stores/useTerminalStore';
let ghosttyPromise: Promise<Ghostty> | null = null;
const loadGhostty = (): Promise<Ghostty> => ghosttyPromise ??= Ghostty.load();
type TerminalSize = { cols: number; rows: number };
const getProvisionalTerminalSize = (
container: HTMLDivElement,
fontFamily: string,
fontSize: number,
): TerminalSize | null => {
if (typeof window === 'undefined' || typeof document === 'undefined') return null;
const context = document.createElement('canvas').getContext('2d');
if (!context || container.clientWidth < 24 || container.clientHeight < 24) return null;
context.font = `${fontSize}px ${fontFamily}`;
const metrics = context.measureText('M');
const cellWidth = Math.ceil(metrics.width);
const cellHeight = Math.ceil(
(metrics.actualBoundingBoxAscent || fontSize * 0.8) +
(metrics.actualBoundingBoxDescent || fontSize * 0.2),
) + 2;
if (cellWidth < 1 || cellHeight < 1) return null;
const style = window.getComputedStyle(container);
const horizontalPadding =
(Number.parseInt(style.paddingLeft, 10) || 0) +
(Number.parseInt(style.paddingRight, 10) || 0);
const verticalPadding =
(Number.parseInt(style.paddingTop, 10) || 0) +
(Number.parseInt(style.paddingBottom, 10) || 0);
// Match Ghostty FitAddon's 15px scrollbar reservation and minimum dimensions.
return {
cols: Math.max(2, Math.floor((container.clientWidth - horizontalPadding - 15) / cellWidth)),
rows: Math.max(1, Math.floor((container.clientHeight - verticalPadding) / cellHeight)),
};
};
export type TerminalController = {
focus: () => void;
fit: () => void;
@@ -47,7 +83,8 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
const fitRef = React.useRef<FitAddon | null>(null);
const inputRef = React.useRef(onInput);
const resizeRef = React.useRef(onResize);
const lastSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
const lastSizeRef = React.useRef<TerminalSize | null>(null);
const provisionalSizeRef = React.useRef<TerminalSize | null>(null);
const lastChunkRef = React.useRef<number | null>(null);
const writeQueueRef = React.useRef('');
const outputRewriteCarryRef = React.useRef('');
@@ -65,6 +102,14 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
visibleRef.current = isVisible;
safeResetRef.current = getGhosttySafeResetSequence(theme.background);
React.useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const size = getProvisionalTerminalSize(container, fontFamily, fontSize);
provisionalSizeRef.current = size;
if (size) resizeRef.current(size.cols, size.rows);
}, [fontFamily, fontSize]);
const fit = React.useCallback(() => {
const container = containerRef.current;
const terminal = terminalRef.current;
@@ -168,7 +213,10 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
loadGhostty().then((ghostty) => {
if (disposed) return;
terminal = new GhosttyTerminal(getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false));
terminal = new GhosttyTerminal({
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
...(provisionalSizeRef.current ?? {}),
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(container);
@@ -26,6 +26,8 @@ type TerminalViewProps = {
visible?: boolean;
};
const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;
export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const { t } = useI18n();
const { terminal, runtime } = useRuntimeAPIs();
@@ -109,7 +111,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
const [isRestarting, setIsRestarting] = React.useState(false);
const [hasViewportSize, setHasViewportSize] = React.useState(false);
const streamCleanupRef = React.useRef<(() => void) | null>(null);
const activeTerminalIdRef = React.useRef<string | null>(null);
@@ -118,7 +119,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const directoryRef = React.useRef<string | null>(effectiveDirectory);
const terminalControllerRef = React.useRef<TerminalController | null>(null);
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
const isTerminalVisibleRef = React.useRef(false);
const pendingTerminalCreatesRef = React.useRef(new Set<string>());
const previewScanTailRef = React.useRef('');
const pendingPreviewProbeUrlsRef = React.useRef<Set<string>>(new Set());
const previewProbeGenerationRef = React.useRef(0);
@@ -157,10 +158,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
}
}, [isTerminalVisible]);
React.useEffect(() => {
isTerminalVisibleRef.current = isTerminalVisible;
}, [isTerminalVisible]);
React.useEffect(() => {
terminalIdRef.current = terminalSessionId;
}, [terminalSessionId]);
@@ -424,7 +421,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
}
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
let terminalId = tab?.terminalSessionId ?? null;
const terminalId = tab?.terminalSessionId ?? null;
const terminalLifecycle = tab?.lifecycle ?? 'idle';
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
const buffer = useTerminalStore.getState().getBuffer(directory, tabId);
@@ -441,11 +438,17 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
return;
}
const size = lastViewportSizeRef.current;
if (!size && isTerminalVisibleRef.current) {
const createKey = `${directory}\u0000${tabId}`;
if (pendingTerminalCreatesRef.current.has(createKey)) {
return;
}
// Launch the shell while Ghostty is still loading and fitting.
// The backend accepts 80x24, then receives the measured size as
// soon as the viewport is ready.
const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;
pendingTerminalCreatesRef.current.add(createKey);
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
@@ -454,8 +457,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const session = await terminal.createSession({
cwd: directory,
sessionId: tabId,
cols: size?.cols,
rows: size?.rows,
cols: initialSize.cols,
rows: initialSize.rows,
shell: terminalShell,
loginShell: terminalLoginShell,
...terminalAppearanceRef.current,
@@ -476,19 +479,38 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
setTabSessionId(directory, tabId, session.sessionId);
if (!stillActive) return;
terminalId = session.sessionId;
} catch (error) {
if (!cancelled) {
setConnectionError(
error instanceof Error
? error.message
: t('terminalView.error.startSessionFailed')
);
setIsFatalError(true);
setIsReconnectPending(false);
setConnecting(directory, tabId, false);
const viewportSize = lastViewportSizeRef.current;
if (
viewportSize &&
(viewportSize.cols !== initialSize.cols || viewportSize.rows !== initialSize.rows)
) {
void terminal.resize({ sessionId: session.sessionId, ...viewportSize }).catch(() => {});
}
// Storing the session ID reruns this effect. Let that next
// effect own stream startup: starting here would be torn
// down immediately by this effect's cleanup.
return;
} catch (error) {
const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId);
if (!owningTab || owningTab.terminalSessionId) return;
setConnecting(directory, tabId, false);
// Strict Mode replaces the first effect while its create
// request is pending. `cancelled` therefore does not mean
// this tab stopped owning the request; use current store
// ownership so a rejected create cannot leave it spinning.
if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return;
setConnectionError(
error instanceof Error
? error.message
: t('terminalView.error.startSessionFailed')
);
setIsFatalError(true);
setIsReconnectPending(false);
return;
} finally {
pendingTerminalCreatesRef.current.delete(createKey);
}
}
@@ -513,7 +535,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
terminalLifecycle,
activeTabId,
hasOpenedTerminalViewport,
hasViewportSize,
enableTabs,
terminalHydrated,
ensureDirectory,
@@ -568,7 +589,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
resetTerminalPreviewScan();
try {
const size = lastViewportSizeRef.current ?? { cols: 80, rows: 24 };
const size = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;
const restarted = await terminal.restartSession(originalSessionId, { cwd: effectiveDirectory, shell: terminalShell, loginShell: terminalLoginShell, ...size, ...terminalAppearanceRef.current });
const owningTab = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId);
if (owningTab?.terminalSessionId !== originalSessionId) return;
@@ -694,22 +715,17 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const handleViewportResize = React.useCallback(
(cols: number, rows: number) => {
const previous = lastViewportSizeRef.current;
if (!previous) {
lastViewportSizeRef.current = { cols, rows };
if (!terminalIdRef.current) setHasViewportSize(true);
} else if (previous.cols !== cols || previous.rows !== rows) {
if (!previous || previous.cols !== cols || previous.rows !== rows) {
lastViewportSizeRef.current = { cols, rows };
}
if (!isTerminalVisibleRef.current) {
if (!isTerminalVisible) {
return;
}
const terminalId = terminalIdRef.current;
if (!terminalId) return;
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {
});
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {});
},
[terminal]
[isTerminalVisible, terminal]
);
const handleModifierToggle = React.useCallback(
@@ -801,11 +817,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
// here tore down and rebuilt the Ghostty terminal (WASM VT + canvas + font
// atlas) a second time the moment `createSession` resolved, doubling the cost
// of every terminal open. Session changes are handled by the chunk replay path.
const terminalViewportKey = React.useMemo(() => {
const directoryPart = effectiveDirectory ?? 'no-dir';
const tabPart = activeTabId ?? 'no-tab';
return `${directoryPart}::${tabPart}`;
}, [effectiveDirectory, activeTabId]);
const terminalViewportKey = `${effectiveDirectory ?? 'no-dir'}::${activeTabId ?? 'no-tab'}`;
React.useEffect(() => {
if (!isTerminalVisible || useTouchTerminalInput) {
@@ -2,8 +2,8 @@
* Regression guard for slow terminal opening on Linux.
*
* `TerminalViewport` is keyed by `terminalViewportKey`. That key used to include
* the PTY session id, which is null until `createSession` resolves. Because the
* viewport must mount first to report its size before a session can be created,
* the PTY session id, which is null until `createSession` resolves. Historically,
* the viewport had to mount first to report its size before session creation, so
* every terminal open built a Ghostty terminal (WASM VT + 2D canvas renderer +
* font atlas), threw it away when the session id arrived, and built a second one.
* The same churn repeated on reconnect and on every incidental session-id change,
@@ -12,6 +12,8 @@
*
* Viewport identity must therefore be directory + tab only. Session changes are
* handled by the chunk replay path, which resets the existing terminal in place.
* New sessions start concurrently with a container-derived size (or 80x24) and
* resize after their viewport fits.
*/
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
@@ -25,27 +27,15 @@ const terminalViewportSource = readFileSync(
'utf-8',
);
const viewportKeyBlock = (() => {
const start = terminalViewSource.indexOf('const terminalViewportKey = React.useMemo(');
expect(start).toBeGreaterThan(-1);
const end = terminalViewSource.indexOf('}, [', start);
expect(end).toBeGreaterThan(start);
return terminalViewSource.slice(start, terminalViewSource.indexOf(');', end));
})();
const viewportKeyDeclaration = terminalViewSource
.split('\n')
.find((line) => line.includes('const terminalViewportKey =')) ?? '';
describe('terminal viewport remount guard', () => {
test('viewport identity excludes the PTY session id', () => {
expect(viewportKeyBlock).toContain('effectiveDirectory');
expect(viewportKeyBlock).toContain('activeTabId');
expect(viewportKeyBlock).not.toContain('terminalSessionId');
});
test('viewport key memo does not depend on the PTY session id', () => {
const dependencyStart = terminalViewSource.indexOf('}, [', terminalViewSource.indexOf('const terminalViewportKey'));
const dependencies = terminalViewSource.slice(dependencyStart, terminalViewSource.indexOf(']', dependencyStart));
expect(dependencies).toContain('effectiveDirectory');
expect(dependencies).toContain('activeTabId');
expect(dependencies).not.toContain('terminalSessionId');
expect(viewportKeyDeclaration).toContain('effectiveDirectory');
expect(viewportKeyDeclaration).toContain('activeTabId');
expect(viewportKeyDeclaration).not.toContain('terminalSessionId');
});
test('replay discontinuities reset the terminal in place instead of remounting it', () => {
@@ -61,4 +51,53 @@ describe('terminal viewport remount guard', () => {
expect(terminalViewSource).toContain('getBuffer(');
expect(terminalViewSource).not.toContain('activeTab?.bufferChunks');
});
test('starts the PTY before Ghostty reports its first viewport size', () => {
expect(terminalViewSource).toContain('const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;');
expect(terminalViewSource).toContain('const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;');
expect(terminalViewSource).not.toContain('if (!size && isTerminalVisibleRef.current)');
expect(terminalViewSource).toContain('cols: initialSize.cols');
expect(terminalViewSource).toContain('rows: initialSize.rows');
expect(terminalViewSource).toContain('void terminal.resize({ sessionId: session.sessionId, ...viewportSize })');
expect(terminalViewSource).toContain('if (!isTerminalVisible) {');
expect(terminalViewSource).not.toContain('isTerminalVisibleRef');
});
test('deduplicates create attempts while the viewport layout settles', () => {
expect(terminalViewSource).toContain('pendingTerminalCreatesRef.current.has(createKey)');
expect(terminalViewSource).toContain('pendingTerminalCreatesRef.current.delete(createKey)');
});
test('lets the session-ID effect own stream startup after creating a tab', () => {
const createStart = terminalViewSource.indexOf('if (!terminalId) {');
const createEnd = terminalViewSource.indexOf('if (!terminalId || cancelled) return;', createStart);
expect(createStart).toBeGreaterThan(-1);
expect(createEnd).toBeGreaterThan(createStart);
const createBlock = terminalViewSource.slice(createStart, createEnd);
expect(createBlock).toContain('setTabSessionId(directory, tabId, session.sessionId);');
expect(createBlock).toContain('Let that next');
expect(createBlock).not.toContain('startStream(');
});
test('clears a current tab from connecting when a strict-mode create rejects', () => {
const createStart = terminalViewSource.indexOf('if (!terminalId) {');
const catchStart = terminalViewSource.indexOf('} catch (error) {', createStart);
const catchEnd = terminalViewSource.indexOf('} finally {', catchStart);
expect(catchStart).toBeGreaterThan(createStart);
expect(catchEnd).toBeGreaterThan(catchStart);
const catchBlock = terminalViewSource.slice(catchStart, catchEnd);
expect(catchBlock).toContain('owningTab.terminalSessionId');
expect(catchBlock).toContain('activeTabIdRef.current !== tabId');
expect(catchBlock).toContain('setConnecting(directory, tabId, false);');
expect(catchBlock).not.toContain('if (!cancelled)');
});
test('derives the initial PTY size before Ghostty mounts', () => {
expect(terminalViewportSource).toContain('const getProvisionalTerminalSize');
expect(terminalViewportSource).toContain('React.useLayoutEffect(() => {');
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
});
});
@@ -29,12 +29,61 @@ mock.module('@/sync/session-ui-store', () => ({
import {
buildQueuedAutoSendPayload,
createQueuedAutoSendRetryScheduler,
getQueuedAutoSendRetryDelayMs,
isQueuedAutoSendBackedOff,
sendQueuedAutoSendPayload,
shouldDispatchQueuedAutoSend,
} from './useQueuedMessageAutoSend';
describe('queued auto-send retry scheduler', () => {
test('wakes the queue when backoff expires', () => {
const callbacks = new Map<number, () => void>();
let nextTimer = 0;
let wakeups = 0;
const scheduler = createQueuedAutoSendRetryScheduler(
() => { wakeups += 1; },
() => 1_000,
(callback, delay) => {
callbacks.set(++nextTimer, callback);
expect(delay).toBe(500);
return nextTimer as unknown as ReturnType<typeof setTimeout>;
},
(timer) => { callbacks.delete(timer as unknown as number); },
);
scheduler.schedule(1_500);
expect(callbacks.size).toBe(1);
callbacks.values().next().value?.();
expect(wakeups).toBe(1);
});
test('keeps the earliest retry and cancels it on dispose', () => {
const callbacks = new Map<number, () => void>();
let nextTimer = 0;
const delays: number[] = [];
const scheduler = createQueuedAutoSendRetryScheduler(
() => undefined,
() => 1_000,
(callback, delay) => {
callbacks.set(++nextTimer, callback);
delays.push(delay);
return nextTimer as unknown as ReturnType<typeof setTimeout>;
},
(timer) => { callbacks.delete(timer as unknown as number); },
);
scheduler.schedule(3_000);
scheduler.schedule(4_000);
scheduler.schedule(2_000);
expect(delays).toEqual([2_000, 1_000]);
expect(callbacks.size).toBe(1);
scheduler.dispose();
expect(callbacks.size).toBe(0);
});
});
describe('shouldDispatchQueuedAutoSend', () => {
test('dispatches only after an active session becomes idle', () => {
expect(shouldDispatchQueuedAutoSend('busy', 'idle', false)).toBe(true);
@@ -33,12 +33,47 @@ export const isQueuedAutoSendBackedOff = (
now: number,
): boolean => failure !== undefined && failure.messageId === messageId && now < failure.nextAttemptAt;
const hasRecentAbort = (sessionId: string): boolean => {
export const createQueuedAutoSendRetryScheduler = (
onWake: () => void,
now: () => number = Date.now,
scheduleTimeout: (callback: () => void, delay: number) => ReturnType<typeof setTimeout> = setTimeout,
cancelTimeout: (timer: ReturnType<typeof setTimeout>) => void = clearTimeout,
) => {
let timer: ReturnType<typeof setTimeout> | null = null;
let scheduledAt: number | null = null;
return {
schedule(retryAt: number) {
if (scheduledAt !== null && scheduledAt <= retryAt) return;
if (timer !== null) cancelTimeout(timer);
scheduledAt = retryAt;
timer = scheduleTimeout(() => {
timer = null;
scheduledAt = null;
onWake();
}, Math.max(0, retryAt - now()));
},
dispose() {
if (timer !== null) cancelTimeout(timer);
timer = null;
scheduledAt = null;
},
};
};
/**
* When the abort window is still open, returns the time it expires so the
* caller can wake the queue then. Returns `null` once sending is allowed
* again a queued item must not wait for an unrelated state change to be
* retried after the window closes.
*/
const getAbortHoldUntil = (sessionId: string): number | null => {
const abortRecord = useSessionUIStore.getState().sessionAbortFlags.get(sessionId);
if (!abortRecord) {
return false;
return null;
}
return Date.now() - abortRecord.timestamp < RECENT_ABORT_WINDOW_MS;
const holdUntil = abortRecord.timestamp + RECENT_ABORT_WINDOW_MS;
return Date.now() < holdUntil ? holdUntil : null;
};
export const buildQueuedAutoSendPayload = (queue: QueuedMessage[]) => {
@@ -149,6 +184,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
const sendFailuresRef = React.useRef<Map<string, QueuedAutoSendFailure>>(new Map());
const previousStatusRef = React.useRef<Map<string, SessionStatusType>>(new Map());
const autoReviewBlockedSessionsRef = React.useRef<Set<string>>(new Set());
const [retryTick, setRetryTick] = React.useState(0);
const retryScheduler = React.useMemo(
() => createQueuedAutoSendRetryScheduler(() => setRetryTick((value) => value + 1)),
[],
);
React.useEffect(() => () => retryScheduler.dispose(), [retryScheduler]);
React.useEffect(() => {
if (!enabled) {
@@ -164,7 +206,9 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
if (inFlightSessionsRef.current.has(targetKey)) {
return;
}
if (hasRecentAbort(sessionId)) {
const abortHoldUntil = getAbortHoldUntil(sessionId);
if (abortHoldUntil !== null) {
retryScheduler.schedule(abortHoldUntil);
return;
}
if (useAutoReviewStore.getState().isRunningForSession(sessionId)) {
@@ -185,7 +229,8 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
const failure = sendFailuresRef.current.get(targetKey);
if (failure && failure.messageId !== payload.queuedMessageId) {
sendFailuresRef.current.delete(targetKey);
} else if (isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) {
} else if (failure && isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) {
retryScheduler.schedule(failure.nextAttemptAt);
return;
}
@@ -195,6 +240,10 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
? captured
: resolveSessionSendConfig(sessionId);
if (!resolved.providerID || !resolved.modelID) {
// Legacy queues may predate captured send configuration. Config
// hydration is asynchronous, so retry instead of stranding the item
// until an unrelated status or directory update happens.
retryScheduler.schedule(Date.now() + AUTO_SEND_RETRY_BASE_DELAY_MS);
return;
}
@@ -213,11 +262,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
console.warn('[queue] queued auto-send failed:', error);
const priorFailures = failure?.messageId === payload.queuedMessageId ? failure.failures : 0;
const failures = priorFailures + 1;
const nextAttemptAt = Date.now() + getQueuedAutoSendRetryDelayMs(failures);
sendFailuresRef.current.set(targetKey, {
messageId: payload.queuedMessageId,
failures,
nextAttemptAt: Date.now() + getQueuedAutoSendRetryDelayMs(failures),
nextAttemptAt,
});
retryScheduler.schedule(nextAttemptAt);
} finally {
inFlightSessionsRef.current.delete(targetKey);
}
@@ -257,5 +308,5 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
});
previousStatusRef.current = nextStatusMap;
}, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory]);
}, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory, retryTick, retryScheduler]);
}
+3 -9
View File
@@ -930,21 +930,15 @@ html:not(.dark) .chat-scroll {
}
}
/* Status row: collapse optional text when narrow to keep both sides in one line.
*
* The todo text goes first, and it goes early. The changed-files summary on the
* left has a floor it cannot shrink past the file count, the +/- counts and
* the chevron are all fixed width so once the two sides no longer fit, they
* collide rather than politely truncating. 30rem was measured against the todo
* text alone; with a changed-files summary beside it the collision starts around
* 36rem, so this hides one step before that. */
/* Hide the long active todo before it can collide with the changed-files summary. */
@container status-row (max-width: 38rem) {
.status-row__active-todo {
display: none;
}
}
@container status-row (max-width: 24rem) {
/* Hide the secondary changed-files label on narrow mobile layouts. */
@container status-row (max-width: 30rem) {
.status-row__changed-label {
display: none;
}
+106 -2
View File
@@ -1,12 +1,19 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionUIStore, getRememberedSessionDirectory } from '@/sync/session-ui-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { checkIsGitRepository } from '@/lib/gitApi';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { copyTextToClipboard as copyPlainTextToClipboard } from '@/lib/clipboard';
import { getSyncSessions, getSyncMessages, getSyncParts } from '@/sync/sync-refs';
import { getSyncSessions, getSyncMessages, getSyncParts, getAllSyncSessions, getSyncSessionDirectory } from '@/sync/sync-refs';
import {
describeSessionDirectorySources,
resolveSessionDirectoryFromSources,
} from '@/sync/session-directory-resolution';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { getRecentSendFailures } from '@/sync/send-failure-log';
import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract';
import { useStreamingStore } from '@/sync/streaming';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -375,12 +382,107 @@ export const debugUtils = {
openchamber: {
settingsInfo,
},
// Empty is a meaningful answer here: it means no prompt was rejected in
// this session, so a "my message disappeared" report is not a rejected
// send and needs a different explanation.
recentSendFailures: getRecentSendFailures(),
currentSessionDirectoryResolution: sessionState.currentSessionId
? this.diagnoseSessionDirectory(sessionState.currentSessionId)
: null,
};
console.log('[DEBUG] App status snapshot:', report);
return report;
},
/**
* Prompt sends that were rejected and rolled back in this app session.
* Newest first; empty means no send was rejected.
*/
getRecentSendFailures() {
const failures = getRecentSendFailures();
if (failures.length === 0) {
console.log('[OK] No prompt sends were rejected in this session.');
} else {
console.warn(`[ALERT] ${failures.length} rejected prompt send(s):`);
console.table(failures);
}
return failures;
},
/**
* Report how a session's directory is resolved, from every source, in
* precedence order. A send is routed by the winning value, so a disagreement
* here explains a prompt that vanishes without an error: it was posted
* against a directory that does not own the session.
*/
diagnoseSessionDirectory(sessionId?: string) {
const sessionState = useSessionUIStore.getState();
const targetSessionId = sessionId ?? sessionState.currentSessionId;
if (!targetSessionId) {
console.log('[ERROR] No session selected and no session id passed');
return null;
}
const attachment = getAttachedSessionDirectory(
useSessionWorktreeStore.getState().getAttachment(targetSessionId),
);
const worktreeMetadata = sessionState.worktreeMetadata.get(targetSessionId)?.path ?? null;
const owningStoreDirectory = getSyncSessionDirectory(targetSessionId);
const sessionRecord = getAllSyncSessions().find((session) => session.id === targetSessionId);
const recordDirectory = (sessionRecord as { directory?: string | null } | undefined)?.directory ?? null;
const selected = targetSessionId === sessionState.currentSessionId
? sessionState.currentSessionDirectory
: null;
const remembered = getRememberedSessionDirectory(targetSessionId);
const sources = {
attachment,
worktreeMetadata,
authoritative: owningStoreDirectory ?? recordDirectory,
selected,
remembered: remembered.runtime,
};
const resolution = resolveSessionDirectoryFromSources(sources);
const routedDirectory = sessionState.getDirectoryForSession(targetSessionId);
const report = {
sessionId: targetSessionId,
isCurrentSession: targetSessionId === sessionState.currentSessionId,
routedDirectory,
resolvedFrom: resolution.source,
conflict: resolution.conflict,
sources: describeSessionDirectorySources(sources),
details: {
owningChildStore: owningStoreDirectory,
sessionRecordDirectory: recordDirectory,
sessionIndexed: Boolean(sessionRecord),
currentSessionDirectory: sessionState.currentSessionDirectory,
rememberedForRuntime: remembered.runtime,
persistedAcrossRestarts: remembered.persisted,
activeDirectory: useDirectoryStore.getState().currentDirectory ?? null,
opencodeClientDirectory: opencodeClient.getDirectory() ?? null,
},
};
console.log('[DEBUG] Session directory resolution:', report);
if (resolution.conflict) {
console.warn(
`[ALERT] Directory sources disagree: using "${resolution.directory}" (${resolution.source}) `
+ `while "${resolution.conflict.directory}" came from ${resolution.conflict.source}.`,
);
} else if (!routedDirectory) {
console.warn('[ALERT] No directory resolved for this session — sends fall back to the active directory.');
} else {
console.log('[OK] All known sources agree on the session directory.');
}
return report;
},
async buildDiagnosticsReport() {
const report = await this.getAppStatus();
return JSON.stringify(report, null, 2);
@@ -697,6 +799,8 @@ if (typeof window !== 'undefined') {
console.log(' __opencodeDebug.getAllMessages(truncate?) - List all messages (truncate=true for short preview)');
console.log(' __opencodeDebug.truncateMessages(messages) - Truncate long fields in messages array');
console.log(' __opencodeDebug.getAppStatus() - Show app status snapshot');
console.log(' __opencodeDebug.diagnoseSessionDirectory(sessionId?) - Show how the session directory is resolved');
console.log(' __opencodeDebug.getRecentSendFailures() - List prompt sends that were rejected and rolled back');
console.log(' __opencodeDebug.checkLastMessage() - Check if last message is problematic');
console.log(' __opencodeDebug.findEmptyMessages() - Find all empty assistant messages');
console.log(' __opencodeDebug.showRetryHelp() - Show instructions for handling empty responses');
@@ -678,9 +678,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" erfolgreich gelöscht',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Skill konnte nicht gelöscht werden',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Skill-Details für Duplizierung konnten nicht geladen werden',
'settings.skills.sidebar.toast.renameLoadFailed': 'Skill-Details konnten nicht geladen werden',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Alter Skill konnte nach Umbenennung nicht entfernt werden',
'settings.skills.sidebar.toast.renameFailed': 'Skill konnte nicht umbenannt werden',
'settings.skills.sidebar.toast.skillRenamed': 'Skill umbenannt in "{name}"',
'settings.skills.sidebar.deleteDialog.title': 'Skill löschen',
'settings.skills.sidebar.deleteDialog.description': 'Möchten Sie den Skill "{name}" wirklich löschen?',
'settings.skills.sidebar.renameDialog.title': 'Skill umbenennen',
@@ -1280,7 +1279,52 @@ export const settingsDict = {
'settings.providers.page.connect.selectProviderPlaceholder': 'Anbieter auswählen',
'settings.providers.page.connect.searchProvidersPlaceholder': 'Suche...',
'settings.providers.page.connect.noProvidersFound': 'Keine Anbieter gefunden',
'settings.providers.page.connect.allProvidersConnected': 'Alle Anbieter verbunden.',
'settings.providers.page.custom.optionLabel': 'Andere / Benutzerdefiniert',
'settings.providers.page.custom.title': 'Benutzerdefinierter Anbieter',
'settings.providers.page.custom.editTitle': 'Benutzerdefinierten Anbieter bearbeiten',
'settings.providers.page.custom.description': 'Fügen Sie einen OpenAI-kompatiblen Anbieter mit Basis-URL, Anmeldedaten und Modellliste hinzu. Wird in der OpenCode-Konfiguration gespeichert und steht im Chat wie jeder andere Anbieter zur Verfügung.',
'settings.providers.page.custom.field.providerID.label': 'Anbieter-ID',
'settings.providers.page.custom.field.providerID.placeholder': 'mein-anbieter',
'settings.providers.page.custom.field.providerID.info': 'Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche. Wird als OpenCode-Anbieter-ID verwendet.',
'settings.providers.page.custom.field.name.label': 'Anzeigename',
'settings.providers.page.custom.field.name.placeholder': 'Mein Anbieter',
'settings.providers.page.custom.field.name.info': 'Wird in den Anbieter- und Modellauswahlen angezeigt.',
'settings.providers.page.custom.field.baseURL.label': 'Basis-URL',
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
'settings.providers.page.custom.field.baseURL.info': 'OpenAI-kompatible API-Basis-URL. Muss mit http:// oder https:// beginnen.',
'settings.providers.page.custom.field.apiKey.label': 'API-Schlüssel',
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... oder {env:VAR_NAME}',
'settings.providers.page.custom.field.apiKey.info': 'Wird in der OpenCode-Authentifizierung gespeichert, nicht von OpenChamber. Verwenden Sie {env:VAR_NAME}, um einen Schlüssel aus der Umgebung zu lesen.',
'settings.providers.page.custom.field.apiKey.editInfo': 'Leer lassen, um die vorhandenen Anmeldedaten zu behalten, oder einen neuen Schlüssel / {env:VAR_NAME} eingeben.',
'settings.providers.page.custom.field.apiKey.editPlaceholder': 'Leer lassen, um den vorhandenen Schlüssel zu behalten',
'settings.providers.page.custom.models.title': 'Modelle',
'settings.providers.page.custom.models.idLabel': 'Modell-ID',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
'settings.providers.page.custom.models.nameLabel': 'Modellname',
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
'settings.providers.page.custom.models.add': 'Modell hinzufügen',
'settings.providers.page.custom.models.remove': 'Modell entfernen',
'settings.providers.page.custom.headers.title': 'Header',
'settings.providers.page.custom.headers.description': 'Optionale Anfrage-Header, die bei jedem Aufruf gesendet werden.',
'settings.providers.page.custom.headers.keyLabel': 'Header-Name',
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
'settings.providers.page.custom.headers.valueLabel': 'Header-Wert',
'settings.providers.page.custom.headers.valuePlaceholder': 'Wert',
'settings.providers.page.custom.headers.add': 'Header hinzufügen',
'settings.providers.page.custom.headers.remove': 'Header entfernen',
'settings.providers.page.custom.actions.back': 'Zurück',
'settings.providers.page.custom.actions.save': 'Anbieter speichern',
'settings.providers.page.custom.actions.update': 'Anbieter aktualisieren',
'settings.providers.page.custom.error.providerID.required': 'Anbieter-ID ist erforderlich',
'settings.providers.page.custom.error.providerID.format': 'Verwenden Sie Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche',
'settings.providers.page.custom.error.providerID.exists': 'Ein Anbieter mit dieser ID ist bereits verbunden',
'settings.providers.page.custom.error.name.required': 'Anzeigename ist erforderlich',
'settings.providers.page.custom.error.baseURL.required': 'Basis-URL ist erforderlich',
'settings.providers.page.custom.error.baseURL.format': 'Basis-URL muss mit http:// oder https:// beginnen',
'settings.providers.page.custom.error.required': 'Erforderlich',
'settings.providers.page.custom.error.duplicate': 'Duplikat',
'settings.providers.page.custom.error.apiKey.required': 'API-Schlüssel oder {env:VAR_NAME} ist erforderlich',
'settings.providers.page.custom.authFailure.configAfterAuth': 'Anmeldedaten wurden gespeichert, aber die Anbieterkonfiguration nicht. Beheben Sie den Fehler und versuchen Sie es erneut, oder trennen Sie die Verbindung, um den teilweisen Speichervorgang zu löschen.',
'settings.providers.page.auth.title': 'Authentifizierung',
'settings.providers.page.auth.loadingMethods': 'Lade Authentifizierungsmethoden...',
'settings.providers.page.auth.apiKeyLabel': 'API-Schlüssel',
@@ -1289,6 +1333,8 @@ export const settingsDict = {
'settings.providers.page.auth.oauthMethodFallback': 'OAuth-Methode {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Autorisierungscode einfügen',
'settings.providers.page.auth.connected': 'Verbunden',
'settings.providers.page.auth.incomplete': 'Anmeldedaten fehlen',
'settings.providers.page.auth.incompleteHint': '· Fügen Sie einen API-Schlüssel oder {env:VAR} hinzu, bevor Sie diesen Anbieter im Chat verwenden',
'settings.providers.page.auth.useReconnectHint': '· Verwenden Sie „Erneut verbinden“, um Anmeldedaten zu aktualisieren',
'settings.providers.page.connectionDetails.title': 'Verbindungsdetails',
'settings.providers.page.connectionDetails.configuredIn': 'Konfiguriert in:',
@@ -1318,6 +1364,7 @@ export const settingsDict = {
'settings.providers.page.actions.complete': 'Vervollständigen',
'settings.providers.page.actions.hide': 'Ausblenden',
'settings.providers.page.actions.reconnect': 'Erneut verbinden',
'settings.providers.page.actions.edit': 'Bearbeiten',
'settings.providers.page.actions.disconnecting': 'Trennen...',
'settings.providers.page.actions.disconnect': 'Trennen',
'settings.providers.page.actions.hideAll': 'Alle ausblenden',
@@ -1338,6 +1385,8 @@ export const settingsDict = {
'settings.providers.page.toast.deviceCodeCopyFailed': 'Fehler beim Kopieren des Gerätecodes',
'settings.providers.page.toast.providerDisconnected': 'Anbieter getrennt',
'settings.providers.page.toast.providerDisconnectFailed': 'Fehler beim Trennen des Anbieters',
'settings.providers.page.toast.customProviderSaved': '{provider} verbunden',
'settings.providers.page.toast.customProviderSaveFailed': 'Benutzerdefinierter Anbieter konnte nicht gespeichert werden',
'settings.mcp.page.empty.selectServer': 'Wählen Sie einen MCP-Server aus der Seitenleiste',
'settings.mcp.page.empty.addNewOne': 'oder fügen Sie einen neuen hinzu',
'settings.mcp.page.header.newServer': 'Neuer MCP-Server',
@@ -730,9 +730,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" deleted successfully',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Failed to delete skill',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Failed to load skill details for duplication',
'settings.skills.sidebar.toast.renameLoadFailed': 'Failed to load skill details',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Failed to remove old skill after rename',
'settings.skills.sidebar.toast.renameFailed': 'Failed to rename skill',
'settings.skills.sidebar.toast.skillRenamed': 'Skill renamed to "{name}"',
'settings.skills.sidebar.deleteDialog.title': 'Delete Skill',
'settings.skills.sidebar.deleteDialog.description': 'Are you sure you want to delete skill "{name}"?',
'settings.skills.sidebar.renameDialog.title': 'Rename Skill',
@@ -1345,7 +1344,52 @@ export const settingsDict = {
'settings.providers.page.connect.selectProviderPlaceholder': 'Select provider',
'settings.providers.page.connect.searchProvidersPlaceholder': 'Search...',
'settings.providers.page.connect.noProvidersFound': 'No providers found',
'settings.providers.page.connect.allProvidersConnected': 'All providers connected.',
'settings.providers.page.custom.optionLabel': 'Other / Custom',
'settings.providers.page.custom.title': 'Custom provider',
'settings.providers.page.custom.editTitle': 'Edit custom provider',
'settings.providers.page.custom.description': 'Add an OpenAI-compatible provider with a base URL, credentials, and model list. Saved to OpenCode config so it works in chat like any other provider.',
'settings.providers.page.custom.field.providerID.label': 'Provider ID',
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
'settings.providers.page.custom.field.providerID.info': 'Lowercase letters, numbers, hyphens, and underscores. Used as the OpenCode provider id.',
'settings.providers.page.custom.field.name.label': 'Display name',
'settings.providers.page.custom.field.name.placeholder': 'My Provider',
'settings.providers.page.custom.field.name.info': 'Shown in the provider and model pickers.',
'settings.providers.page.custom.field.baseURL.label': 'Base URL',
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
'settings.providers.page.custom.field.baseURL.info': 'OpenAI-compatible API base URL. Must start with http:// or https://.',
'settings.providers.page.custom.field.apiKey.label': 'API key',
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... or {env:VAR_NAME}',
'settings.providers.page.custom.field.apiKey.info': 'Stored in OpenCode auth, not by OpenChamber. Use {env:VAR_NAME} to read a key from the environment instead.',
'settings.providers.page.custom.field.apiKey.editInfo': 'Leave blank to keep the existing credential, or enter a new key / {env:VAR_NAME}.',
'settings.providers.page.custom.field.apiKey.editPlaceholder': 'Leave blank to keep existing key',
'settings.providers.page.custom.models.title': 'Models',
'settings.providers.page.custom.models.idLabel': 'Model ID',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
'settings.providers.page.custom.models.nameLabel': 'Model name',
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
'settings.providers.page.custom.models.add': 'Add model',
'settings.providers.page.custom.models.remove': 'Remove model',
'settings.providers.page.custom.headers.title': 'Headers',
'settings.providers.page.custom.headers.description': 'Optional request headers sent with every call.',
'settings.providers.page.custom.headers.keyLabel': 'Header name',
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
'settings.providers.page.custom.headers.valueLabel': 'Header value',
'settings.providers.page.custom.headers.valuePlaceholder': 'value',
'settings.providers.page.custom.headers.add': 'Add header',
'settings.providers.page.custom.headers.remove': 'Remove header',
'settings.providers.page.custom.actions.back': 'Back',
'settings.providers.page.custom.actions.save': 'Save provider',
'settings.providers.page.custom.actions.update': 'Update provider',
'settings.providers.page.custom.error.providerID.required': 'Provider ID is required',
'settings.providers.page.custom.error.providerID.format': 'Use lowercase letters, numbers, hyphens, or underscores',
'settings.providers.page.custom.error.providerID.exists': 'A provider with this ID is already connected',
'settings.providers.page.custom.error.name.required': 'Display name is required',
'settings.providers.page.custom.error.baseURL.required': 'Base URL is required',
'settings.providers.page.custom.error.baseURL.format': 'Base URL must start with http:// or https://',
'settings.providers.page.custom.error.required': 'Required',
'settings.providers.page.custom.error.duplicate': 'Duplicate',
'settings.providers.page.custom.error.apiKey.required': 'API key or {env:VAR_NAME} is required',
'settings.providers.page.custom.authFailure.configAfterAuth': 'Credentials were saved, but the provider config was not. Fix the error and try again, or disconnect to clear the partial save.',
'settings.providers.page.auth.title': 'Authentication',
'settings.providers.page.auth.loadingMethods': 'Loading authentication methods...',
'settings.providers.page.auth.apiKeyLabel': 'API Key',
@@ -1354,6 +1398,8 @@ export const settingsDict = {
'settings.providers.page.auth.oauthMethodFallback': 'OAuth method {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Paste authorization code',
'settings.providers.page.auth.connected': 'Connected',
'settings.providers.page.auth.incomplete': 'Credentials missing',
'settings.providers.page.auth.incompleteHint': '· Add an API key or {env:VAR} before using this provider in chat',
'settings.providers.page.auth.useReconnectHint': '· Use Reconnect to update credentials',
'settings.providers.page.connectionDetails.title': 'Connection Details',
'settings.providers.page.connectionDetails.configuredIn': 'Configured in:',
@@ -1383,6 +1429,7 @@ export const settingsDict = {
'settings.providers.page.actions.complete': 'Complete',
'settings.providers.page.actions.hide': 'Hide',
'settings.providers.page.actions.reconnect': 'Reconnect',
'settings.providers.page.actions.edit': 'Edit',
'settings.providers.page.actions.disconnecting': 'Disconnecting...',
'settings.providers.page.actions.disconnect': 'Disconnect',
'settings.providers.page.actions.hideAll': 'Hide all',
@@ -1403,6 +1450,8 @@ export const settingsDict = {
'settings.providers.page.toast.deviceCodeCopyFailed': 'Failed to copy device code',
'settings.providers.page.toast.providerDisconnected': 'Provider disconnected',
'settings.providers.page.toast.providerDisconnectFailed': 'Failed to disconnect provider',
'settings.providers.page.toast.customProviderSaved': '{provider} connected',
'settings.providers.page.toast.customProviderSaveFailed': 'Failed to save custom provider',
'settings.mcp.page.empty.selectServer': 'Select an MCP server from the sidebar',
'settings.mcp.page.empty.addNewOne': 'or add a new one',
'settings.mcp.page.header.newServer': 'New MCP Server',
@@ -698,9 +698,8 @@ export const settingsDict = {
"settings.skills.sidebar.toast.skillDeleted": "Habilidad \"{name}\" eliminada con éxito",
"settings.skills.sidebar.toast.deleteSkillFailed": "No se pudo eliminar la habilidad",
"settings.skills.sidebar.toast.duplicateLoadFailed": "No se pudo cargar la información de la habilidad para duplicarla",
"settings.skills.sidebar.toast.renameLoadFailed": "No se pudo cargar la información de la habilidad",
"settings.skills.sidebar.toast.removeOldAfterRenameFailed": "No se pudo eliminar la habilidad antigua después del cambio de nombre",
"settings.skills.sidebar.toast.renameFailed": "No se pudo cambiar el nombre de la habilidad",
"settings.skills.sidebar.toast.skillRenamed": "Habilidad renombrada a \"{name}\"",
"settings.skills.sidebar.deleteDialog.title": "Eliminar habilidad",
"settings.skills.sidebar.deleteDialog.description": "¿Estás seguro de que quieres eliminar la habilidad \"{name}\"?",
"settings.skills.sidebar.renameDialog.title": "Cambiar nombre habilidad",
@@ -1313,7 +1312,58 @@ export const settingsDict = {
"settings.providers.page.connect.selectProviderPlaceholder": "Seleccionar proveedor",
"settings.providers.page.connect.searchProvidersPlaceholder": "Buscar...",
"settings.providers.page.connect.noProvidersFound": "No se encontraron proveedores",
"settings.providers.page.connect.allProvidersConnected": "Todos los proveedores están conectados.",
"settings.providers.page.custom.optionLabel": "Otro / Personalizado",
"settings.providers.page.custom.title": "Proveedor personalizado",
"settings.providers.page.custom.editTitle": "Editar proveedor personalizado",
"settings.providers.page.custom.description": "Añade un proveedor compatible con OpenAI con URL base, credenciales y lista de modelos. Se guarda en la configuración de OpenCode para usarlo en el chat como cualquier otro proveedor.",
"settings.providers.page.custom.field.providerID.label": "ID del proveedor",
"settings.providers.page.custom.field.providerID.placeholder": "mi-proveedor",
"settings.providers.page.custom.field.providerID.info": "Minúsculas, números, guiones y guiones bajos. Se usa como ID de proveedor de OpenCode.",
"settings.providers.page.custom.field.name.label": "Nombre visible",
"settings.providers.page.custom.field.name.placeholder": "Mi proveedor",
"settings.providers.page.custom.field.name.info": "Se muestra en los selectores de proveedor y modelo.",
"settings.providers.page.custom.field.baseURL.label": "URL base",
"settings.providers.page.custom.field.baseURL.placeholder": "https://api.example.com/v1",
"settings.providers.page.custom.field.baseURL.info": "URL base de la API compatible con OpenAI. Debe empezar por http:// o https://.",
"settings.providers.page.custom.field.apiKey.label": "Clave API",
"settings.providers.page.custom.field.apiKey.placeholder": "sk-... o {env:VAR_NAME}",
"settings.providers.page.custom.field.apiKey.info": "Se guarda en la autenticación de OpenCode, no en OpenChamber. Usa {env:VAR_NAME} para leer una clave del entorno.",
"settings.providers.page.custom.field.apiKey.editInfo": "Déjalo en blanco para conservar la credencial existente, o introduce una clave nueva / {env:VAR_NAME}.",
"settings.providers.page.custom.field.apiKey.editPlaceholder": "Déjalo en blanco para conservar la clave existente",
"settings.providers.page.custom.models.title": "Modelos",
"settings.providers.page.custom.models.idLabel": "ID del modelo",
"settings.providers.page.custom.models.idPlaceholder": "gpt-4o",
"settings.providers.page.custom.models.nameLabel": "Nombre del modelo",
"settings.providers.page.custom.models.namePlaceholder": "GPT-4o",
"settings.providers.page.custom.models.add": "Añadir modelo",
"settings.providers.page.custom.models.remove": "Quitar modelo",
"settings.providers.page.custom.headers.title": "Encabezados",
"settings.providers.page.custom.headers.description": "Encabezados de solicitud opcionales enviados en cada llamada.",
"settings.providers.page.custom.headers.keyLabel": "Nombre del encabezado",
"settings.providers.page.custom.headers.keyPlaceholder": "X-Custom-Header",
"settings.providers.page.custom.headers.valueLabel": "Valor del encabezado",
"settings.providers.page.custom.headers.valuePlaceholder": "valor",
"settings.providers.page.custom.headers.add": "Añadir encabezado",
"settings.providers.page.custom.headers.remove": "Quitar encabezado",
"settings.providers.page.custom.actions.back": "Atrás",
"settings.providers.page.custom.actions.save": "Guardar proveedor",
"settings.providers.page.custom.actions.update": "Actualizar proveedor",
"settings.providers.page.custom.error.providerID.required": "El ID del proveedor es obligatorio",
"settings.providers.page.custom.error.providerID.format": "Usa minúsculas, números, guiones o guiones bajos",
"settings.providers.page.custom.error.providerID.exists": "Ya hay un proveedor conectado con este ID",
"settings.providers.page.custom.error.name.required": "El nombre visible es obligatorio",
"settings.providers.page.custom.error.baseURL.required": "La URL base es obligatoria",
"settings.providers.page.custom.error.baseURL.format": "La URL base debe empezar por http:// o https://",
"settings.providers.page.custom.error.required": "Obligatorio",
"settings.providers.page.custom.error.duplicate": "Duplicado",
"settings.providers.page.custom.error.apiKey.required": "Se requiere una clave API o {env:VAR_NAME}",
"settings.providers.page.custom.authFailure.configAfterAuth": "Las credenciales se guardaron, pero no la configuración del proveedor. Corrige el error e inténtalo de nuevo, o desconéctalo para eliminar el guardado parcial.",
"settings.providers.page.auth.title": "Autenticación",
"settings.providers.page.auth.loadingMethods": "Cargando métodos de autenticación...",
"settings.providers.page.auth.apiKeyLabel": "Clave API",
@@ -1322,6 +1372,10 @@ export const settingsDict = {
"settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}",
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Pegar código de autorización",
"settings.providers.page.auth.connected": "Conectado",
"settings.providers.page.auth.incomplete": "Faltan credenciales",
"settings.providers.page.auth.incompleteHint": "· Añade una clave API o {env:VAR} antes de usar este proveedor en el chat",
"settings.providers.page.auth.useReconnectHint": "· Usar Reconnect para actualizar credenciales",
"settings.providers.page.connectionDetails.title": "Detalles de conexión",
"settings.providers.page.connectionDetails.configuredIn": "Configurado en:",
@@ -1351,6 +1405,8 @@ export const settingsDict = {
"settings.providers.page.actions.complete": "Completar",
"settings.providers.page.actions.hide": "Ocultar",
"settings.providers.page.actions.reconnect": "Reconectar",
"settings.providers.page.actions.edit": "Editar",
"settings.providers.page.actions.disconnecting": "Desconectando...",
"settings.providers.page.actions.disconnect": "Desconectar",
"settings.providers.page.actions.hideAll": "Ocultar todo",
@@ -1371,6 +1427,8 @@ export const settingsDict = {
"settings.providers.page.toast.deviceCodeCopyFailed": "No se pudo copiar el código de dispositivo",
"settings.providers.page.toast.providerDisconnected": "Proveedor desconectado",
"settings.providers.page.toast.providerDisconnectFailed": "No se pudo desconectar el proveedor",
"settings.providers.page.toast.customProviderSaved": "{provider} conectado",
"settings.providers.page.toast.customProviderSaveFailed": "No se pudo guardar el proveedor personalizado",
"settings.mcp.page.empty.selectServer": "Selecciona un servidor MCP desde el panel lateral",
"settings.mcp.page.empty.addNewOne": "o añade uno nuevo",
"settings.mcp.page.header.newServer": "Nuevo servidor MCP",
@@ -619,9 +619,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" supprimé avec succès',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Échec de la suppression du skill',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Échec du chargement des détails du skill pour la duplication',
'settings.skills.sidebar.toast.renameLoadFailed': 'Échec du chargement des détails du skill',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Échec de la suppression de l\'ancien skill après le renommage',
'settings.skills.sidebar.toast.renameFailed': 'Échec du renommage du skill',
'settings.skills.sidebar.toast.skillRenamed': 'Skill renommé en "{name}"',
'settings.skills.sidebar.deleteDialog.title': 'Supprimer le skill',
'settings.skills.sidebar.deleteDialog.description': 'Êtes-vous sûr de vouloir supprimer le skill « {name} » ?',
'settings.skills.sidebar.renameDialog.title': 'Renommer le skill',
@@ -1234,7 +1233,58 @@ export const settingsDict = {
'settings.providers.page.connect.selectProviderPlaceholder': 'Sélectionnez le fournisseur',
'settings.providers.page.connect.searchProvidersPlaceholder': 'Recherche...',
'settings.providers.page.connect.noProvidersFound': 'Aucun fournisseur trouvé',
'settings.providers.page.connect.allProvidersConnected': 'Tous les fournisseurs connectés.',
'settings.providers.page.custom.optionLabel': 'Autre / Personnalisé',
'settings.providers.page.custom.title': 'Fournisseur personnalisé',
'settings.providers.page.custom.editTitle': 'Modifier le fournisseur personnalisé',
'settings.providers.page.custom.description': 'Ajoutez un fournisseur compatible OpenAI avec une URL de base, des identifiants et une liste de modèles. Enregistré dans la configuration OpenCode pour lutiliser dans le chat comme les autres fournisseurs.',
'settings.providers.page.custom.field.providerID.label': 'ID du fournisseur',
'settings.providers.page.custom.field.providerID.placeholder': 'mon-fournisseur',
'settings.providers.page.custom.field.providerID.info': 'Minuscules, chiffres, tirets et underscores. Utilisé comme ID de fournisseur OpenCode.',
'settings.providers.page.custom.field.name.label': 'Nom affiché',
'settings.providers.page.custom.field.name.placeholder': 'Mon fournisseur',
'settings.providers.page.custom.field.name.info': 'Affiché dans les sélecteurs de fournisseur et de modèle.',
'settings.providers.page.custom.field.baseURL.label': 'URL de base',
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
'settings.providers.page.custom.field.baseURL.info': 'URL de base de lAPI compatible OpenAI. Doit commencer par http:// ou https://.',
'settings.providers.page.custom.field.apiKey.label': 'Clé API',
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... ou {env:VAR_NAME}',
'settings.providers.page.custom.field.apiKey.info': 'Stockée dans lauth OpenCode, pas par OpenChamber. Utilisez {env:VAR_NAME} pour lire une clé depuis lenvironnement.',
'settings.providers.page.custom.field.apiKey.editInfo': 'Laissez vide pour conserver l\'identifiant existant, ou saisissez une nouvelle clé / {env:VAR_NAME}.',
'settings.providers.page.custom.field.apiKey.editPlaceholder': 'Laissez vide pour conserver la clé existante',
'settings.providers.page.custom.models.title': 'Modèles',
'settings.providers.page.custom.models.idLabel': 'ID du modèle',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
'settings.providers.page.custom.models.nameLabel': 'Nom du modèle',
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
'settings.providers.page.custom.models.add': 'Ajouter un modèle',
'settings.providers.page.custom.models.remove': 'Supprimer le modèle',
'settings.providers.page.custom.headers.title': 'En-têtes',
'settings.providers.page.custom.headers.description': 'En-têtes de requête optionnels envoyés à chaque appel.',
'settings.providers.page.custom.headers.keyLabel': 'Nom de len-tête',
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
'settings.providers.page.custom.headers.valueLabel': 'Valeur de len-tête',
'settings.providers.page.custom.headers.valuePlaceholder': 'valeur',
'settings.providers.page.custom.headers.add': 'Ajouter un en-tête',
'settings.providers.page.custom.headers.remove': 'Supprimer len-tête',
'settings.providers.page.custom.actions.back': 'Retour',
'settings.providers.page.custom.actions.save': 'Enregistrer le fournisseur',
'settings.providers.page.custom.actions.update': 'Mettre à jour le fournisseur',
'settings.providers.page.custom.error.providerID.required': 'LID du fournisseur est obligatoire',
'settings.providers.page.custom.error.providerID.format': 'Utilisez des minuscules, chiffres, tirets ou underscores',
'settings.providers.page.custom.error.providerID.exists': 'Un fournisseur avec cet ID est déjà connecté',
'settings.providers.page.custom.error.name.required': 'Le nom affiché est obligatoire',
'settings.providers.page.custom.error.baseURL.required': 'LURL de base est obligatoire',
'settings.providers.page.custom.error.baseURL.format': 'LURL de base doit commencer par http:// ou https://',
'settings.providers.page.custom.error.required': 'Obligatoire',
'settings.providers.page.custom.error.duplicate': 'Doublon',
'settings.providers.page.custom.error.apiKey.required': 'Une clé API ou {env:VAR_NAME} est requise',
'settings.providers.page.custom.authFailure.configAfterAuth': 'Les identifiants ont été enregistrés, mais pas la configuration du fournisseur. Corrigez l\'erreur et réessayez, ou déconnectez pour effacer l\'enregistrement partiel.',
'settings.providers.page.auth.title': 'Authentification',
'settings.providers.page.auth.loadingMethods': 'Chargement des méthodes d\'authentification...',
'settings.providers.page.auth.apiKeyLabel': 'Clé API',
@@ -1243,6 +1293,10 @@ export const settingsDict = {
'settings.providers.page.auth.oauthMethodFallback': 'Méthode OAuth {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Coller le code d\'autorisation',
'settings.providers.page.auth.connected': 'Connecté',
'settings.providers.page.auth.incomplete': 'Identifiants manquants',
'settings.providers.page.auth.incompleteHint': '· Ajoutez une clé API ou {env:VAR} avant dutiliser ce fournisseur dans le chat',
'settings.providers.page.auth.useReconnectHint': '· Utilisez Reconnect pour mettre à jour les informations d\'identification',
'settings.providers.page.connectionDetails.title': 'Détails de connexion',
'settings.providers.page.connectionDetails.configuredIn': 'Configuré dans :',
@@ -1272,6 +1326,8 @@ export const settingsDict = {
'settings.providers.page.actions.complete': 'Complet',
'settings.providers.page.actions.hide': 'Cacher',
'settings.providers.page.actions.reconnect': 'Reconnecter',
'settings.providers.page.actions.edit': 'Modifier',
'settings.providers.page.actions.disconnecting': 'Déconnexion...',
'settings.providers.page.actions.disconnect': 'Déconnecter',
'settings.providers.page.actions.hideAll': 'Tout cacher',
@@ -1292,6 +1348,8 @@ export const settingsDict = {
'settings.providers.page.toast.deviceCodeCopyFailed': 'Échec de la copie du code de l\'appareil',
'settings.providers.page.toast.providerDisconnected': 'Fournisseur déconnecté',
'settings.providers.page.toast.providerDisconnectFailed': 'Échec de la déconnexion du fournisseur',
'settings.providers.page.toast.customProviderSaved': '{provider} connecté',
'settings.providers.page.toast.customProviderSaveFailed': 'Échec de lenregistrement du fournisseur personnalisé',
'settings.mcp.page.empty.selectServer': 'Sélectionnez un serveur MCP dans la barre latérale',
'settings.mcp.page.empty.addNewOne': 'ou ajoutez-en un nouveau',
'settings.mcp.page.header.newServer': 'Nouveau serveur MCP',
@@ -731,9 +731,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" を削除しました',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Skill の削除に失敗しました',
'settings.skills.sidebar.toast.duplicateLoadFailed': '複製用の Skill 詳細の読み込みに失敗しました',
'settings.skills.sidebar.toast.renameLoadFailed': 'Skill 詳細の読み込みに失敗しました',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '名前変更後に古い Skill の削除に失敗しました',
'settings.skills.sidebar.toast.renameFailed': 'Skill の名前変更に失敗しました',
'settings.skills.sidebar.toast.skillRenamed': 'Skill の名前を「{name}」に変更しました',
'settings.skills.sidebar.deleteDialog.title': 'Skill を削除',
'settings.skills.sidebar.deleteDialog.description': 'Skill "{name}" を削除してもよろしいですか?',
'settings.skills.sidebar.renameDialog.title': 'Skill の名前変更',
@@ -1346,7 +1345,58 @@ export const settingsDict = {
'settings.providers.page.connect.selectProviderPlaceholder': 'Provider を選択',
'settings.providers.page.connect.searchProvidersPlaceholder': '検索...',
'settings.providers.page.connect.noProvidersFound': 'Provider が見つかりません',
'settings.providers.page.connect.allProvidersConnected': 'すべての Provider が接続されています。',
'settings.providers.page.custom.optionLabel': 'その他 / カスタム',
'settings.providers.page.custom.title': 'カスタムプロバイダー',
'settings.providers.page.custom.editTitle': 'カスタムプロバイダーを編集',
'settings.providers.page.custom.description': 'ベース URL・認証情報・モデル一覧を指定して、OpenAI 互換プロバイダーを追加します。OpenCode 設定に保存され、他のプロバイダーと同様にチャットで使えます。',
'settings.providers.page.custom.field.providerID.label': 'プロバイダー ID',
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
'settings.providers.page.custom.field.providerID.info': '小文字・数字・ハイフン・アンダースコア。OpenCode のプロバイダー ID として使われます。',
'settings.providers.page.custom.field.name.label': '表示名',
'settings.providers.page.custom.field.name.placeholder': 'My Provider',
'settings.providers.page.custom.field.name.info': 'プロバイダーおよびモデル選択に表示されます。',
'settings.providers.page.custom.field.baseURL.label': 'ベース URL',
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
'settings.providers.page.custom.field.baseURL.info': 'OpenAI 互換 API のベース URL。http:// または https:// で始めてください。',
'settings.providers.page.custom.field.apiKey.label': 'API キー',
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... または {env:VAR_NAME}',
'settings.providers.page.custom.field.apiKey.info': 'OpenChamber ではなく OpenCode の認証に保存されます。環境変数から読む場合は {env:VAR_NAME} を使います。',
'settings.providers.page.custom.field.apiKey.editInfo': '空のままにすると既存の認証情報を保持します。新しいキーまたは {env:VAR_NAME} を入力することもできます。',
'settings.providers.page.custom.field.apiKey.editPlaceholder': '空のままにすると既存のキーを保持',
'settings.providers.page.custom.models.title': 'モデル',
'settings.providers.page.custom.models.idLabel': 'モデル ID',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
'settings.providers.page.custom.models.nameLabel': 'モデル名',
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
'settings.providers.page.custom.models.add': 'モデルを追加',
'settings.providers.page.custom.models.remove': 'モデルを削除',
'settings.providers.page.custom.headers.title': 'ヘッダー',
'settings.providers.page.custom.headers.description': '各リクエストに付ける任意のヘッダーです。',
'settings.providers.page.custom.headers.keyLabel': 'ヘッダー名',
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
'settings.providers.page.custom.headers.valueLabel': 'ヘッダー値',
'settings.providers.page.custom.headers.valuePlaceholder': 'value',
'settings.providers.page.custom.headers.add': 'ヘッダーを追加',
'settings.providers.page.custom.headers.remove': 'ヘッダーを削除',
'settings.providers.page.custom.actions.back': '戻る',
'settings.providers.page.custom.actions.save': 'プロバイダーを保存',
'settings.providers.page.custom.actions.update': 'プロバイダーを更新',
'settings.providers.page.custom.error.providerID.required': 'プロバイダー ID は必須です',
'settings.providers.page.custom.error.providerID.format': '小文字・数字・ハイフン・アンダースコアを使ってください',
'settings.providers.page.custom.error.providerID.exists': 'この ID のプロバイダーは既に接続されています',
'settings.providers.page.custom.error.name.required': '表示名は必須です',
'settings.providers.page.custom.error.baseURL.required': 'ベース URL は必須です',
'settings.providers.page.custom.error.baseURL.format': 'ベース URL は http:// または https:// で始めてください',
'settings.providers.page.custom.error.required': '必須',
'settings.providers.page.custom.error.duplicate': '重複',
'settings.providers.page.custom.error.apiKey.required': 'API キーまたは {env:VAR_NAME} が必要です',
'settings.providers.page.custom.authFailure.configAfterAuth': '認証情報は保存されましたが、プロバイダー設定は保存されませんでした。エラーを修正して再試行するか、切断して不完全な保存を削除してください。',
'settings.providers.page.auth.title': '認証',
'settings.providers.page.auth.loadingMethods': '認証方法を読み込み中...',
'settings.providers.page.auth.apiKeyLabel': 'API キー',
@@ -1355,6 +1405,10 @@ export const settingsDict = {
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方法 {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '認証コードを貼り付け',
'settings.providers.page.auth.connected': '接続済み',
'settings.providers.page.auth.incomplete': '認証情報が不足しています',
'settings.providers.page.auth.incompleteHint': '· チャットでこのプロバイダーを使う前に API キーまたは {env:VAR} を追加してください',
'settings.providers.page.auth.useReconnectHint': '· 認証情報を更新するには再接続を使用',
'settings.providers.page.connectionDetails.title': '接続詳細',
'settings.providers.page.connectionDetails.configuredIn': '設定場所:',
@@ -1384,6 +1438,8 @@ export const settingsDict = {
'settings.providers.page.actions.complete': '完了',
'settings.providers.page.actions.hide': '非表示',
'settings.providers.page.actions.reconnect': '再接続',
'settings.providers.page.actions.edit': '編集',
'settings.providers.page.actions.disconnecting': '切断中...',
'settings.providers.page.actions.disconnect': '切断',
'settings.providers.page.actions.hideAll': 'すべて非表示',
@@ -1404,6 +1460,8 @@ export const settingsDict = {
'settings.providers.page.toast.deviceCodeCopyFailed': 'デバイスコードのコピーに失敗しました',
'settings.providers.page.toast.providerDisconnected': 'Provider を切断しました',
'settings.providers.page.toast.providerDisconnectFailed': 'Provider の切断に失敗しました',
'settings.providers.page.toast.customProviderSaved': '{provider} を接続しました',
'settings.providers.page.toast.customProviderSaveFailed': 'カスタムプロバイダーの保存に失敗しました',
'settings.mcp.page.empty.selectServer': 'サイドバーから MCP サーバーを選択してください',
'settings.mcp.page.empty.addNewOne': 'または新しいものを追加',
'settings.mcp.page.header.newServer': '新しい MCP サーバー',
@@ -698,9 +698,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': '스킬 "{name}"을 삭제했습니다',
'settings.skills.sidebar.toast.deleteSkillFailed': '스킬을 삭제하지 못했습니다',
'settings.skills.sidebar.toast.duplicateLoadFailed': '복제를 위한 스킬 세부 정보를 로드하지 못했습니다',
'settings.skills.sidebar.toast.renameLoadFailed': '스킬 세부 정보를 로드하지 못했습니다',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '이름 변경 후 이전 스킬을 제거하지 못했습니다',
'settings.skills.sidebar.toast.renameFailed': '스킬 이름을 변경하지 못했습니다',
'settings.skills.sidebar.toast.skillRenamed': '스킬 이름이 "{name}"(으)로 변경되었습니다',
'settings.skills.sidebar.deleteDialog.title': '스킬 삭제',
'settings.skills.sidebar.deleteDialog.description': '스킬 "{name}"을 삭제하시겠습니까?',
'settings.skills.sidebar.renameDialog.title': '스킬 이름 변경',
@@ -1313,7 +1312,58 @@ export const settingsDict = {
'settings.providers.page.connect.selectProviderPlaceholder': '프로바이더 선택',
'settings.providers.page.connect.searchProvidersPlaceholder': '검색...',
'settings.providers.page.connect.noProvidersFound': '프로바이더를 찾을 수 없습니다',
'settings.providers.page.connect.allProvidersConnected': '모든 프로바이더가 연결되었습니다.',
'settings.providers.page.custom.optionLabel': '기타 / 사용자 정의',
'settings.providers.page.custom.title': '사용자 정의 제공자',
'settings.providers.page.custom.editTitle': '사용자 지정 공급자 편집',
'settings.providers.page.custom.description': '기본 URL, 자격 증명, 모델 목록으로 OpenAI 호환 제공자를 추가합니다. OpenCode 설정에 저장되어 다른 제공자와 같이 채팅에서 사용할 수 있습니다.',
'settings.providers.page.custom.field.providerID.label': '제공자 ID',
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
'settings.providers.page.custom.field.providerID.info': '소문자, 숫자, 하이픈, 밑줄. OpenCode 제공자 ID로 사용됩니다.',
'settings.providers.page.custom.field.name.label': '표시 이름',
'settings.providers.page.custom.field.name.placeholder': '내 제공자',
'settings.providers.page.custom.field.name.info': '제공자 및 모델 선택기에 표시됩니다.',
'settings.providers.page.custom.field.baseURL.label': '기본 URL',
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
'settings.providers.page.custom.field.baseURL.info': 'OpenAI 호환 API 기본 URL. http:// 또는 https://로 시작해야 합니다.',
'settings.providers.page.custom.field.apiKey.label': 'API 키',
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... 또는 {env:VAR_NAME}',
'settings.providers.page.custom.field.apiKey.info': 'OpenChamber가 아니라 OpenCode 인증에 저장됩니다. 환경 변수에서 읽으려면 {env:VAR_NAME}을 사용하세요.',
'settings.providers.page.custom.field.apiKey.editInfo': '비워 두면 기존 자격 증명을 유지합니다. 새 키 또는 {env:VAR_NAME}을(를) 입력할 수도 있습니다.',
'settings.providers.page.custom.field.apiKey.editPlaceholder': '비워 두면 기존 키 유지',
'settings.providers.page.custom.models.title': '모델',
'settings.providers.page.custom.models.idLabel': '모델 ID',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
'settings.providers.page.custom.models.nameLabel': '모델 이름',
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
'settings.providers.page.custom.models.add': '모델 추가',
'settings.providers.page.custom.models.remove': '모델 제거',
'settings.providers.page.custom.headers.title': '헤더',
'settings.providers.page.custom.headers.description': '매 호출에 전송되는 선택적 요청 헤더입니다.',
'settings.providers.page.custom.headers.keyLabel': '헤더 이름',
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
'settings.providers.page.custom.headers.valueLabel': '헤더 값',
'settings.providers.page.custom.headers.valuePlaceholder': 'value',
'settings.providers.page.custom.headers.add': '헤더 추가',
'settings.providers.page.custom.headers.remove': '헤더 제거',
'settings.providers.page.custom.actions.back': '뒤로',
'settings.providers.page.custom.actions.save': '제공자 저장',
'settings.providers.page.custom.actions.update': '공급자 업데이트',
'settings.providers.page.custom.error.providerID.required': '제공자 ID는 필수입니다',
'settings.providers.page.custom.error.providerID.format': '소문자, 숫자, 하이픈, 밑줄을 사용하세요',
'settings.providers.page.custom.error.providerID.exists': '이 ID의 제공자가 이미 연결되어 있습니다',
'settings.providers.page.custom.error.name.required': '표시 이름은 필수입니다',
'settings.providers.page.custom.error.baseURL.required': '기본 URL은 필수입니다',
'settings.providers.page.custom.error.baseURL.format': '기본 URL은 http:// 또는 https://로 시작해야 합니다',
'settings.providers.page.custom.error.required': '필수',
'settings.providers.page.custom.error.duplicate': '중복',
'settings.providers.page.custom.error.apiKey.required': 'API 키 또는 {env:VAR_NAME}이(가) 필요합니다',
'settings.providers.page.custom.authFailure.configAfterAuth': '자격 증명은 저장되었지만 공급자 구성은 저장되지 않았습니다. 오류를 수정한 뒤 다시 시도하거나, 연결을 해제하여 부분 저장을 지우세요.',
'settings.providers.page.auth.title': '인증',
'settings.providers.page.auth.loadingMethods': '인증 방식 로딩 중...',
'settings.providers.page.auth.apiKeyLabel': 'API Key',
@@ -1322,6 +1372,10 @@ export const settingsDict = {
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 방식 {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'authorization code 붙여넣기',
'settings.providers.page.auth.connected': '연결됨',
'settings.providers.page.auth.incomplete': '자격 증명 없음',
'settings.providers.page.auth.incompleteHint': '· 채팅에서 이 공급자를 사용하기 전에 API 키 또는 {env:VAR}을(를) 추가하세요',
'settings.providers.page.auth.useReconnectHint': '· 인증 정보를 업데이트하려면 Reconnect를 사용하세요',
'settings.providers.page.connectionDetails.title': '연결 세부 정보',
'settings.providers.page.connectionDetails.configuredIn': '설정 위치:',
@@ -1351,6 +1405,8 @@ export const settingsDict = {
'settings.providers.page.actions.complete': '완료',
'settings.providers.page.actions.hide': '숨기기',
'settings.providers.page.actions.reconnect': '재연결',
'settings.providers.page.actions.edit': '편집',
'settings.providers.page.actions.disconnecting': '연결 해제 중...',
'settings.providers.page.actions.disconnect': '연결 해제',
'settings.providers.page.actions.hideAll': '모두 숨기기',
@@ -1371,6 +1427,8 @@ export const settingsDict = {
'settings.providers.page.toast.deviceCodeCopyFailed': '기기 코드를 복사하지 못했습니다',
'settings.providers.page.toast.providerDisconnected': '프로바이더 연결이 해제되었습니다',
'settings.providers.page.toast.providerDisconnectFailed': '프로바이더 연결을 해제하지 못했습니다',
'settings.providers.page.toast.customProviderSaved': '{provider} 연결됨',
'settings.providers.page.toast.customProviderSaveFailed': '사용자 정의 제공자를 저장하지 못했습니다',
'settings.mcp.page.empty.selectServer': '사이드바에서 MCP 서버를 선택하세요',
'settings.mcp.page.empty.addNewOne': '또는 새로 추가하세요',
'settings.mcp.page.header.newServer': '새 MCP 서버',
@@ -1368,6 +1368,8 @@ export const settingsDict = {
'settings.providers.page.actions.hideAll': 'Ukryj wszystko',
'settings.providers.page.actions.open': 'Otwórz',
'settings.providers.page.actions.reconnect': 'Połącz ponownie',
'settings.providers.page.actions.edit': 'Edytuj',
'settings.providers.page.actions.saveKey': 'Zapisz klucz',
'settings.providers.page.actions.saving': 'Zapisywanie...',
'settings.providers.page.actions.showAll': 'Pokaż wszystko',
@@ -1375,12 +1377,67 @@ export const settingsDict = {
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
'settings.providers.page.auth.apiKeyTooltip': 'Klucze są wysyłane bezpośrednio do OpenCode i nigdy nie są przechowywane przez OpenChamber.',
'settings.providers.page.auth.connected': 'Połączono',
'settings.providers.page.auth.incomplete': 'Brak poświadczeń',
'settings.providers.page.auth.incompleteHint': '· Dodaj klucz API lub {env:VAR} przed użyciem tego dostawcy w czacie',
'settings.providers.page.auth.loadingMethods': 'Ładowanie metod uwierzytelniania...',
'settings.providers.page.auth.oauthMethodFallback': 'Metoda OAuth {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Wklej kod autoryzacyjny',
'settings.providers.page.auth.title': 'Uwierzytelnianie',
'settings.providers.page.auth.useReconnectHint': '· Użyj Połącz ponownie, aby zaktualizować dane logowania',
'settings.providers.page.connect.allProvidersConnected': 'Wszyscy dostawcy są połączeni.',
'settings.providers.page.custom.optionLabel': 'Inny / Niestandardowy',
'settings.providers.page.custom.title': 'Niestandardowy dostawca',
'settings.providers.page.custom.editTitle': 'Edytuj niestandardowego dostawcę',
'settings.providers.page.custom.description': 'Dodaj dostawcę zgodnego z OpenAI, podając adres bazowy, poświadczenia i listę modeli. Zapisuje się w konfiguracji OpenCode i działa w czacie jak każdy inny dostawca.',
'settings.providers.page.custom.field.providerID.label': 'ID dostawcy',
'settings.providers.page.custom.field.providerID.placeholder': 'moj-dostawca',
'settings.providers.page.custom.field.providerID.info': 'Małe litery, cyfry, myślniki i podkreślenia. Używane jako ID dostawcy OpenCode.',
'settings.providers.page.custom.field.name.label': 'Nazwa wyświetlana',
'settings.providers.page.custom.field.name.placeholder': 'Mój dostawca',
'settings.providers.page.custom.field.name.info': 'Widoczna w selektorach dostawcy i modelu.',
'settings.providers.page.custom.field.baseURL.label': 'Adres bazowy',
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
'settings.providers.page.custom.field.baseURL.info': 'Bazowy URL API zgodnego z OpenAI. Musi zaczynać się od http:// lub https://.',
'settings.providers.page.custom.field.apiKey.label': 'Klucz API',
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... lub {env:VAR_NAME}',
'settings.providers.page.custom.field.apiKey.info': 'Przechowywany w auth OpenCode, nie przez OpenChamber. Użyj {env:VAR_NAME}, aby odczytać klucz ze zmiennej środowiskowej.',
'settings.providers.page.custom.field.apiKey.editInfo': 'Pozostaw puste, aby zachować istniejące poświadczenie, albo wpisz nowy klucz / {env:VAR_NAME}.',
'settings.providers.page.custom.field.apiKey.editPlaceholder': 'Pozostaw puste, aby zachować istniejący klucz',
'settings.providers.page.custom.models.title': 'Modele',
'settings.providers.page.custom.models.idLabel': 'ID modelu',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
'settings.providers.page.custom.models.nameLabel': 'Nazwa modelu',
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
'settings.providers.page.custom.models.add': 'Dodaj model',
'settings.providers.page.custom.models.remove': 'Usuń model',
'settings.providers.page.custom.headers.title': 'Nagłówki',
'settings.providers.page.custom.headers.description': 'Opcjonalne nagłówki żądania wysyłane przy każdym wywołaniu.',
'settings.providers.page.custom.headers.keyLabel': 'Nazwa nagłówka',
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
'settings.providers.page.custom.headers.valueLabel': 'Wartość nagłówka',
'settings.providers.page.custom.headers.valuePlaceholder': 'wartość',
'settings.providers.page.custom.headers.add': 'Dodaj nagłówek',
'settings.providers.page.custom.headers.remove': 'Usuń nagłówek',
'settings.providers.page.custom.actions.back': 'Wstecz',
'settings.providers.page.custom.actions.save': 'Zapisz dostawcę',
'settings.providers.page.custom.actions.update': 'Zaktualizuj dostawcę',
'settings.providers.page.custom.error.providerID.required': 'ID dostawcy jest wymagane',
'settings.providers.page.custom.error.providerID.format': 'Użyj małych liter, cyfr, myślników lub podkreśleń',
'settings.providers.page.custom.error.providerID.exists': 'Dostawca o tym ID jest już połączony',
'settings.providers.page.custom.error.name.required': 'Nazwa wyświetlana jest wymagana',
'settings.providers.page.custom.error.baseURL.required': 'Adres bazowy jest wymagany',
'settings.providers.page.custom.error.baseURL.format': 'Adres bazowy musi zaczynać się od http:// lub https://',
'settings.providers.page.custom.error.required': 'Wymagane',
'settings.providers.page.custom.error.duplicate': 'Duplikat',
'settings.providers.page.custom.error.apiKey.required': 'Wymagany jest klucz API lub {env:VAR_NAME}',
'settings.providers.page.custom.authFailure.configAfterAuth': 'Poświadczenia zostały zapisane, ale konfiguracja dostawcy nie. Napraw błąd i spróbuj ponownie albo rozłącz, aby usunąć częściowy zapis.',
'settings.providers.page.connect.noProvidersFound': 'Nie znaleziono dostawców',
'settings.providers.page.connect.providerField': 'Dostawca',
'settings.providers.page.connect.searchProvidersPlaceholder': 'Szukaj...',
@@ -1426,6 +1483,8 @@ export const settingsDict = {
'settings.providers.page.toast.oauthLinkCopyFailed': 'Nie udało się skopiować linku OAuth',
'settings.providers.page.toast.oauthStartFailed': 'Nie udało się rozpocząć procesu OAuth',
'settings.providers.page.toast.providerDisconnectFailed': 'Nie udało się odłączyć dostawcy',
'settings.providers.page.toast.customProviderSaved': 'Połączono {provider}',
'settings.providers.page.toast.customProviderSaveFailed': 'Nie udało się zapisać niestandardowego dostawcy',
'settings.providers.page.toast.providerDisconnected': 'Dostawca został odłączony',
'settings.providers.page.toast.providerSourcesLoadFailed': 'Nie udało się załadować źródeł dostawcy',
'settings.providers.sidebar.actions.connectProviderAria': 'Połącz dostawcę',
@@ -1863,10 +1922,9 @@ export const settingsDict = {
'settings.skills.sidebar.title': 'Umiejętności',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Nie udało się usunąć umiejętności',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Nie udało się załadować szczegółów umiejętności do duplikacji',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Nie udało się usunąć starej umiejętności po zmianie nazwy',
'settings.skills.sidebar.toast.renameFailed': 'Nie udało się zmienić nazwy umiejętności',
'settings.skills.sidebar.toast.renameLoadFailed': 'Nie udało się załadować szczegółów umiejętności',
'settings.skills.sidebar.toast.skillDeleted': 'Umiejętność „{name}” została usunięta',
'settings.skills.sidebar.toast.skillRenamed': 'Zmieniono nazwę umiejętności na „{name}”',
'settings.skills.sidebar.total': 'Suma: {count}',
'settings.usage.pace.prediction': 'Prognoza: {prediction}',
'settings.usage.pace.predictionLabel': 'Prognoza: ',
@@ -698,9 +698,8 @@ export const settingsDict = {
"settings.skills.sidebar.toast.skillDeleted": "Habilidade \"{name}\" excluída com sucesso",
"settings.skills.sidebar.toast.deleteSkillFailed": "Não foi possível excluir a habilidade",
"settings.skills.sidebar.toast.duplicateLoadFailed": "Não foi possível carregar as informações da habilidade para duplicá-la",
"settings.skills.sidebar.toast.renameLoadFailed": "Não foi possível carregar as informações da habilidade",
"settings.skills.sidebar.toast.removeOldAfterRenameFailed": "Não foi possível excluir a habilidade antiga depois da renomeação",
"settings.skills.sidebar.toast.renameFailed": "Não foi possível renomear da habilidade",
"settings.skills.sidebar.toast.skillRenamed": "Habilidade renomeada para \"{name}\"",
"settings.skills.sidebar.deleteDialog.title": "Excluir habilidade",
"settings.skills.sidebar.deleteDialog.description": "Tem certeza de que deseja excluir a habilidade \"{name}\"?",
"settings.skills.sidebar.renameDialog.title": "Renomear habilidade",
@@ -1313,7 +1312,58 @@ export const settingsDict = {
"settings.providers.page.connect.selectProviderPlaceholder": "Selecionar provedor",
"settings.providers.page.connect.searchProvidersPlaceholder": "Buscar...",
"settings.providers.page.connect.noProvidersFound": "Nenhum provedores",
"settings.providers.page.connect.allProvidersConnected": "Todos os provedores estão conectados.",
"settings.providers.page.custom.optionLabel": "Outro / Personalizado",
"settings.providers.page.custom.title": "Provedor personalizado",
"settings.providers.page.custom.editTitle": "Editar provedor personalizado",
"settings.providers.page.custom.description": "Adicione um provedor compatível com OpenAI com URL base, credenciais e lista de modelos. Salvo na configuração do OpenCode para uso no chat como qualquer outro provedor.",
"settings.providers.page.custom.field.providerID.label": "ID do provedor",
"settings.providers.page.custom.field.providerID.placeholder": "meu-provedor",
"settings.providers.page.custom.field.providerID.info": "Letras minúsculas, números, hífens e sublinhados. Usado como ID de provedor do OpenCode.",
"settings.providers.page.custom.field.name.label": "Nome de exibição",
"settings.providers.page.custom.field.name.placeholder": "Meu provedor",
"settings.providers.page.custom.field.name.info": "Mostrado nos seletores de provedor e modelo.",
"settings.providers.page.custom.field.baseURL.label": "URL base",
"settings.providers.page.custom.field.baseURL.placeholder": "https://api.example.com/v1",
"settings.providers.page.custom.field.baseURL.info": "URL base da API compatível com OpenAI. Deve começar com http:// ou https://.",
"settings.providers.page.custom.field.apiKey.label": "Chave de API",
"settings.providers.page.custom.field.apiKey.placeholder": "sk-... ou {env:VAR_NAME}",
"settings.providers.page.custom.field.apiKey.info": "Armazenada na autenticação do OpenCode, não pelo OpenChamber. Use {env:VAR_NAME} para ler uma chave do ambiente.",
"settings.providers.page.custom.field.apiKey.editInfo": "Deixe em branco para manter a credencial existente, ou informe uma nova chave / {env:VAR_NAME}.",
"settings.providers.page.custom.field.apiKey.editPlaceholder": "Deixe em branco para manter a chave existente",
"settings.providers.page.custom.models.title": "Modelos",
"settings.providers.page.custom.models.idLabel": "ID do modelo",
"settings.providers.page.custom.models.idPlaceholder": "gpt-4o",
"settings.providers.page.custom.models.nameLabel": "Nome do modelo",
"settings.providers.page.custom.models.namePlaceholder": "GPT-4o",
"settings.providers.page.custom.models.add": "Adicionar modelo",
"settings.providers.page.custom.models.remove": "Remover modelo",
"settings.providers.page.custom.headers.title": "Cabeçalhos",
"settings.providers.page.custom.headers.description": "Cabeçalhos de solicitação opcionais enviados em cada chamada.",
"settings.providers.page.custom.headers.keyLabel": "Nome do cabeçalho",
"settings.providers.page.custom.headers.keyPlaceholder": "X-Custom-Header",
"settings.providers.page.custom.headers.valueLabel": "Valor do cabeçalho",
"settings.providers.page.custom.headers.valuePlaceholder": "valor",
"settings.providers.page.custom.headers.add": "Adicionar cabeçalho",
"settings.providers.page.custom.headers.remove": "Remover cabeçalho",
"settings.providers.page.custom.actions.back": "Voltar",
"settings.providers.page.custom.actions.save": "Salvar provedor",
"settings.providers.page.custom.actions.update": "Atualizar provedor",
"settings.providers.page.custom.error.providerID.required": "O ID do provedor é obrigatório",
"settings.providers.page.custom.error.providerID.format": "Use letras minúsculas, números, hífens ou sublinhados",
"settings.providers.page.custom.error.providerID.exists": "Já existe um provedor conectado com este ID",
"settings.providers.page.custom.error.name.required": "O nome de exibição é obrigatório",
"settings.providers.page.custom.error.baseURL.required": "A URL base é obrigatória",
"settings.providers.page.custom.error.baseURL.format": "A URL base deve começar com http:// ou https://",
"settings.providers.page.custom.error.required": "Obrigatório",
"settings.providers.page.custom.error.duplicate": "Duplicado",
"settings.providers.page.custom.error.apiKey.required": "É necessária uma chave de API ou {env:VAR_NAME}",
"settings.providers.page.custom.authFailure.configAfterAuth": "As credenciais foram salvas, mas a configuração do provedor não. Corrija o erro e tente novamente, ou desconecte para limpar o salvamento parcial.",
"settings.providers.page.auth.title": "Autenticação",
"settings.providers.page.auth.loadingMethods": "Carregando métodos de autenticação...",
"settings.providers.page.auth.apiKeyLabel": "Chave API",
@@ -1322,6 +1372,10 @@ export const settingsDict = {
"settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}",
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Colar código de autorização",
"settings.providers.page.auth.connected": "Conectado",
"settings.providers.page.auth.incomplete": "Credenciais ausentes",
"settings.providers.page.auth.incompleteHint": "· Adicione uma chave de API ou {env:VAR} antes de usar este provedor no chat",
"settings.providers.page.auth.useReconnectHint": "· Usar Reconnect para atualizar credenciais",
"settings.providers.page.connectionDetails.title": "Detalhes de conexão",
"settings.providers.page.connectionDetails.configuredIn": "Configuredo en:",
@@ -1351,6 +1405,8 @@ export const settingsDict = {
"settings.providers.page.actions.complete": "Completar",
"settings.providers.page.actions.hide": "Ocultar",
"settings.providers.page.actions.reconnect": "Reconectar",
"settings.providers.page.actions.edit": "Editar",
"settings.providers.page.actions.disconnecting": "Desconectando...",
"settings.providers.page.actions.disconnect": "Desconectar",
"settings.providers.page.actions.hideAll": "Ocultar todo",
@@ -1371,6 +1427,8 @@ export const settingsDict = {
"settings.providers.page.toast.deviceCodeCopyFailed": "Não foi possível copiar o código de dispositivo",
"settings.providers.page.toast.providerDisconnected": "Provedor desconectado",
"settings.providers.page.toast.providerDisconnectFailed": "Não foi possível desconectar o provedor",
"settings.providers.page.toast.customProviderSaved": "{provider} conectado",
"settings.providers.page.toast.customProviderSaveFailed": "Falha ao salvar o provedor personalizado",
"settings.mcp.page.empty.selectServer": "Selecione um servidor MCP de o painel lateral",
"settings.mcp.page.empty.addNewOne": "o añade um novo",
"settings.mcp.page.header.newServer": "Novo servidor MCP",
@@ -698,9 +698,8 @@ export const settingsDict = {
"settings.skills.sidebar.toast.skillDeleted": "Навичку \"{name}\" успішно видалено",
"settings.skills.sidebar.toast.deleteSkillFailed": "Не вдалося видалити навичку",
"settings.skills.sidebar.toast.duplicateLoadFailed": "Не вдалося завантажити деталі навичок для дублювання",
"settings.skills.sidebar.toast.renameLoadFailed": "Не вдалося завантажити деталі навичок",
"settings.skills.sidebar.toast.removeOldAfterRenameFailed": "Не вдалося видалити стару навичку після перейменування",
"settings.skills.sidebar.toast.renameFailed": "Не вдалося перейменувати навичку",
"settings.skills.sidebar.toast.skillRenamed": "Навичку перейменовано на \"{name}\"",
"settings.skills.sidebar.deleteDialog.title": "Видалити навичку",
"settings.skills.sidebar.deleteDialog.description": "Ви впевнені, що бажаєте видалити навичку «{name}»?",
"settings.skills.sidebar.renameDialog.title": "Перейменувати навичку",
@@ -1313,7 +1312,58 @@ export const settingsDict = {
"settings.providers.page.connect.selectProviderPlaceholder": "Виберіть провайдера",
"settings.providers.page.connect.searchProvidersPlaceholder": "Пошук...",
"settings.providers.page.connect.noProvidersFound": "Немає провайдерів",
"settings.providers.page.connect.allProvidersConnected": "Усі провайдери підключені.",
"settings.providers.page.custom.optionLabel": "Інший / Власний",
"settings.providers.page.custom.title": "Власний провайдер",
"settings.providers.page.custom.editTitle": "Редагувати власного провайдера",
"settings.providers.page.custom.description": "Додайте OpenAI-сумісного провайдера з базовою URL-адресою, обліковими даними та списком моделей. Зберігається в конфігурації OpenCode й працює в чаті як будь-який інший провайдер.",
"settings.providers.page.custom.field.providerID.label": "ID провайдера",
"settings.providers.page.custom.field.providerID.placeholder": "mij-provider",
"settings.providers.page.custom.field.providerID.info": "Малі літери, цифри, дефіси та підкреслення. Використовується як ID провайдера OpenCode.",
"settings.providers.page.custom.field.name.label": "Відображувана назва",
"settings.providers.page.custom.field.name.placeholder": "Мій провайдер",
"settings.providers.page.custom.field.name.info": "Показується у виборі провайдера та моделі.",
"settings.providers.page.custom.field.baseURL.label": "Базова URL-адреса",
"settings.providers.page.custom.field.baseURL.placeholder": "https://api.example.com/v1",
"settings.providers.page.custom.field.baseURL.info": "Базова URL-адреса OpenAI-сумісного API. Має починатися з http:// або https://.",
"settings.providers.page.custom.field.apiKey.label": "API-ключ",
"settings.providers.page.custom.field.apiKey.placeholder": "sk-... або {env:VAR_NAME}",
"settings.providers.page.custom.field.apiKey.info": "Зберігається в автентифікації OpenCode, не OpenChamber. Використовуйте {env:VAR_NAME}, щоб читати ключ зі змінної середовища.",
"settings.providers.page.custom.field.apiKey.editInfo": "Залиште порожнім, щоб зберегти наявні облікові дані, або введіть новий ключ / {env:VAR_NAME}.",
"settings.providers.page.custom.field.apiKey.editPlaceholder": "Залиште порожнім, щоб зберегти наявний ключ",
"settings.providers.page.custom.models.title": "Моделі",
"settings.providers.page.custom.models.idLabel": "ID моделі",
"settings.providers.page.custom.models.idPlaceholder": "gpt-4o",
"settings.providers.page.custom.models.nameLabel": "Назва моделі",
"settings.providers.page.custom.models.namePlaceholder": "GPT-4o",
"settings.providers.page.custom.models.add": "Додати модель",
"settings.providers.page.custom.models.remove": "Видалити модель",
"settings.providers.page.custom.headers.title": "Заголовки",
"settings.providers.page.custom.headers.description": "Необов’язкові заголовки запиту, що надсилаються з кожним викликом.",
"settings.providers.page.custom.headers.keyLabel": "Назва заголовка",
"settings.providers.page.custom.headers.keyPlaceholder": "X-Custom-Header",
"settings.providers.page.custom.headers.valueLabel": "Значення заголовка",
"settings.providers.page.custom.headers.valuePlaceholder": "значення",
"settings.providers.page.custom.headers.add": "Додати заголовок",
"settings.providers.page.custom.headers.remove": "Видалити заголовок",
"settings.providers.page.custom.actions.back": "Назад",
"settings.providers.page.custom.actions.save": "Зберегти провайдера",
"settings.providers.page.custom.actions.update": "Оновити провайдера",
"settings.providers.page.custom.error.providerID.required": "ID провайдера обов’язковий",
"settings.providers.page.custom.error.providerID.format": "Використовуйте малі літери, цифри, дефіси або підкреслення",
"settings.providers.page.custom.error.providerID.exists": "Провайдер із цим ID уже підключено",
"settings.providers.page.custom.error.name.required": "Відображувана назва обов’язкова",
"settings.providers.page.custom.error.baseURL.required": "Базова URL-адреса обов’язкова",
"settings.providers.page.custom.error.baseURL.format": "Базова URL-адреса має починатися з http:// або https://",
"settings.providers.page.custom.error.required": "Обов’язково",
"settings.providers.page.custom.error.duplicate": "Дублікат",
"settings.providers.page.custom.error.apiKey.required": "Потрібен API-ключ або {env:VAR_NAME}",
"settings.providers.page.custom.authFailure.configAfterAuth": "Облікові дані збережено, але конфігурацію провайдера — ні. Виправте помилку й спробуйте знову або від’єднайте, щоб очистити часткове збереження.",
"settings.providers.page.auth.title": "Аутентифікація",
"settings.providers.page.auth.loadingMethods": "Завантаження методів автентифікації...",
"settings.providers.page.auth.apiKeyLabel": "API ключ",
@@ -1322,6 +1372,10 @@ export const settingsDict = {
"settings.providers.page.auth.oauthMethodFallback": "OAuth метод {index}",
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Вставити код авторизації",
"settings.providers.page.auth.connected": "Підключено",
"settings.providers.page.auth.incomplete": "Облікові дані відсутні",
"settings.providers.page.auth.incompleteHint": "· Додайте API-ключ або {env:VAR} перед використанням цього провайдера в чаті",
"settings.providers.page.auth.useReconnectHint": "· Скористайтеся повторним підключенням, щоб оновити облікові дані",
"settings.providers.page.connectionDetails.title": "Деталі підключення",
"settings.providers.page.connectionDetails.configuredIn": "Налаштовано в:",
@@ -1351,6 +1405,8 @@ export const settingsDict = {
"settings.providers.page.actions.complete": "Завершити",
"settings.providers.page.actions.hide": "Сховати",
"settings.providers.page.actions.reconnect": "Перепідключити",
"settings.providers.page.actions.edit": "Редагувати",
"settings.providers.page.actions.disconnecting": "Відключення...",
"settings.providers.page.actions.disconnect": "Відключити",
"settings.providers.page.actions.hideAll": "Сховати все",
@@ -1371,6 +1427,8 @@ export const settingsDict = {
"settings.providers.page.toast.deviceCodeCopyFailed": "Не вдалося скопіювати код пристрою",
"settings.providers.page.toast.providerDisconnected": "Провайдера відключено",
"settings.providers.page.toast.providerDisconnectFailed": "Не вдалося відключити провайдера",
"settings.providers.page.toast.customProviderSaved": "{provider} підключено",
"settings.providers.page.toast.customProviderSaveFailed": "Не вдалося зберегти власного провайдера",
"settings.mcp.page.empty.selectServer": "Виберіть MCP сервер на бічній панелі",
"settings.mcp.page.empty.addNewOne": "або додати новий",
"settings.mcp.page.header.newServer": "Новий сервер MCP",
@@ -698,9 +698,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': '技能“{name}”已删除',
'settings.skills.sidebar.toast.deleteSkillFailed': '删除技能失败',
'settings.skills.sidebar.toast.duplicateLoadFailed': '加载技能详情以复制失败',
'settings.skills.sidebar.toast.renameLoadFailed': '加载技能详情失败',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '重命名后移除旧技能失败',
'settings.skills.sidebar.toast.renameFailed': '重命名技能失败',
'settings.skills.sidebar.toast.skillRenamed': '技能已重命名为“{name}”',
'settings.skills.sidebar.deleteDialog.title': '删除技能',
'settings.skills.sidebar.deleteDialog.description': '确定要删除技能“{name}”吗?',
'settings.skills.sidebar.renameDialog.title': '重命名技能',
@@ -1313,7 +1312,58 @@ export const settingsDict = {
'settings.providers.page.connect.selectProviderPlaceholder': '选择提供商',
'settings.providers.page.connect.searchProvidersPlaceholder': '搜索...',
'settings.providers.page.connect.noProvidersFound': '未找到提供商',
'settings.providers.page.connect.allProvidersConnected': '所有提供商均已连接。',
'settings.providers.page.custom.optionLabel': '其他 / 自定义',
'settings.providers.page.custom.title': '自定义提供商',
'settings.providers.page.custom.editTitle': '编辑自定义提供商',
'settings.providers.page.custom.description': '通过指定基础 URL、凭据和模型列表,添加兼容 OpenAI 的提供商。会写入 OpenCode 配置,可像其他提供商一样在聊天中使用。',
'settings.providers.page.custom.field.providerID.label': '提供商 ID',
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
'settings.providers.page.custom.field.providerID.info': '小写字母、数字、连字符和下划线。用作 OpenCode 提供商 ID。',
'settings.providers.page.custom.field.name.label': '显示名称',
'settings.providers.page.custom.field.name.placeholder': '我的提供商',
'settings.providers.page.custom.field.name.info': '显示在提供商和模型选择器中。',
'settings.providers.page.custom.field.baseURL.label': '基础 URL',
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
'settings.providers.page.custom.field.baseURL.info': '兼容 OpenAI 的 API 基础 URL。必须以 http:// 或 https:// 开头。',
'settings.providers.page.custom.field.apiKey.label': 'API 密钥',
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... 或 {env:VAR_NAME}',
'settings.providers.page.custom.field.apiKey.info': '保存在 OpenCode 认证中,而非 OpenChamber。使用 {env:VAR_NAME} 可从环境变量读取密钥。',
'settings.providers.page.custom.field.apiKey.editInfo': '留空以保留现有凭据,或输入新密钥 / {env:VAR_NAME}。',
'settings.providers.page.custom.field.apiKey.editPlaceholder': '留空以保留现有密钥',
'settings.providers.page.custom.models.title': '模型',
'settings.providers.page.custom.models.idLabel': '模型 ID',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
'settings.providers.page.custom.models.nameLabel': '模型名称',
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
'settings.providers.page.custom.models.add': '添加模型',
'settings.providers.page.custom.models.remove': '移除模型',
'settings.providers.page.custom.headers.title': '请求头',
'settings.providers.page.custom.headers.description': '每次调用可选发送的请求头。',
'settings.providers.page.custom.headers.keyLabel': '请求头名称',
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
'settings.providers.page.custom.headers.valueLabel': '请求头值',
'settings.providers.page.custom.headers.valuePlaceholder': 'value',
'settings.providers.page.custom.headers.add': '添加请求头',
'settings.providers.page.custom.headers.remove': '移除请求头',
'settings.providers.page.custom.actions.back': '返回',
'settings.providers.page.custom.actions.save': '保存提供商',
'settings.providers.page.custom.actions.update': '更新提供商',
'settings.providers.page.custom.error.providerID.required': '提供商 ID 为必填项',
'settings.providers.page.custom.error.providerID.format': '请使用小写字母、数字、连字符或下划线',
'settings.providers.page.custom.error.providerID.exists': '已连接具有此 ID 的提供商',
'settings.providers.page.custom.error.name.required': '显示名称为必填项',
'settings.providers.page.custom.error.baseURL.required': '基础 URL 为必填项',
'settings.providers.page.custom.error.baseURL.format': '基础 URL 必须以 http:// 或 https:// 开头',
'settings.providers.page.custom.error.required': '必填',
'settings.providers.page.custom.error.duplicate': '重复',
'settings.providers.page.custom.error.apiKey.required': '需要 API 密钥或 {env:VAR_NAME}',
'settings.providers.page.custom.authFailure.configAfterAuth': '凭据已保存,但提供商配置未保存。请修复错误后重试,或断开连接以清除部分保存。',
'settings.providers.page.auth.title': '认证',
'settings.providers.page.auth.loadingMethods': '正在加载认证方式...',
'settings.providers.page.auth.apiKeyLabel': 'API Key',
@@ -1322,6 +1372,10 @@ export const settingsDict = {
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '粘贴授权码',
'settings.providers.page.auth.connected': '已连接',
'settings.providers.page.auth.incomplete': '缺少凭据',
'settings.providers.page.auth.incompleteHint': '· 在聊天中使用此提供商之前,请添加 API 密钥或 {env:VAR}',
'settings.providers.page.auth.useReconnectHint': '· 使用“重新连接”以更新凭据',
'settings.providers.page.connectionDetails.title': '连接详情',
'settings.providers.page.connectionDetails.configuredIn': '配置来源:',
@@ -1351,6 +1405,8 @@ export const settingsDict = {
'settings.providers.page.actions.complete': '完成',
'settings.providers.page.actions.hide': '隐藏',
'settings.providers.page.actions.reconnect': '重新连接',
'settings.providers.page.actions.edit': '编辑',
'settings.providers.page.actions.disconnecting': '断开连接中...',
'settings.providers.page.actions.disconnect': '断开连接',
'settings.providers.page.actions.hideAll': '全部隐藏',
@@ -1371,6 +1427,8 @@ export const settingsDict = {
'settings.providers.page.toast.deviceCodeCopyFailed': '复制设备代码失败',
'settings.providers.page.toast.providerDisconnected': '提供商已断开连接',
'settings.providers.page.toast.providerDisconnectFailed': '断开提供商连接失败',
'settings.providers.page.toast.customProviderSaved': '已连接 {provider}',
'settings.providers.page.toast.customProviderSaveFailed': '保存自定义提供商失败',
'settings.mcp.page.empty.selectServer': '请从侧边栏选择一个 MCP 服务器',
'settings.mcp.page.empty.addNewOne': '或添加一个新的',
'settings.mcp.page.header.newServer': '新建 MCP 服务器',
@@ -695,9 +695,8 @@
'settings.skills.sidebar.toast.skillDeleted': 'skill「{name}」已刪除',
'settings.skills.sidebar.toast.deleteSkillFailed': '刪除 skill 失敗',
'settings.skills.sidebar.toast.duplicateLoadFailed': '複製 skill 的詳細資訊載入失敗',
'settings.skills.sidebar.toast.renameLoadFailed': '載入 skill 詳情失敗',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '重新命名後移除舊 skill 失敗',
'settings.skills.sidebar.toast.renameFailed': '重新命名 skill 失敗',
'settings.skills.sidebar.toast.skillRenamed': 'skill 已重新命名為「{name}」',
'settings.skills.sidebar.deleteDialog.title': '刪除 Skill',
'settings.skills.sidebar.deleteDialog.description': '確定要刪除 skill「{name}」嗎?',
'settings.skills.sidebar.renameDialog.title': '重新命名 Skill',
@@ -1219,7 +1218,58 @@
'settings.providers.page.connect.selectProviderPlaceholder': '選擇供應商',
'settings.providers.page.connect.searchProvidersPlaceholder': '搜尋...',
'settings.providers.page.connect.noProvidersFound': '找不到供應商',
'settings.providers.page.connect.allProvidersConnected': '所有供應商均已連線。',
'settings.providers.page.custom.optionLabel': '其他 / 自訂',
'settings.providers.page.custom.title': '自訂供應商',
'settings.providers.page.custom.editTitle': '編輯自訂提供者',
'settings.providers.page.custom.description': '透過指定基礎 URL、憑證與模型清單,新增相容 OpenAI 的供應商。會寫入 OpenCode 設定,可像其他供應商一樣在聊天中使用。',
'settings.providers.page.custom.field.providerID.label': '供應商 ID',
'settings.providers.page.custom.field.providerID.placeholder': 'my-provider',
'settings.providers.page.custom.field.providerID.info': '小寫字母、數字、連字號與底線。用作 OpenCode 供應商 ID。',
'settings.providers.page.custom.field.name.label': '顯示名稱',
'settings.providers.page.custom.field.name.placeholder': '我的供應商',
'settings.providers.page.custom.field.name.info': '顯示於供應商與模型選擇器。',
'settings.providers.page.custom.field.baseURL.label': '基礎 URL',
'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1',
'settings.providers.page.custom.field.baseURL.info': '相容 OpenAI 的 API 基礎 URL。必須以 http:// 或 https:// 開頭。',
'settings.providers.page.custom.field.apiKey.label': 'API 金鑰',
'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... 或 {env:VAR_NAME}',
'settings.providers.page.custom.field.apiKey.info': '儲存在 OpenCode 驗證中,而非 OpenChamber。使用 {env:VAR_NAME} 可從環境變數讀取金鑰。',
'settings.providers.page.custom.field.apiKey.editInfo': '留空以保留現有憑證,或輸入新金鑰 / {env:VAR_NAME}。',
'settings.providers.page.custom.field.apiKey.editPlaceholder': '留空以保留現有金鑰',
'settings.providers.page.custom.models.title': '模型',
'settings.providers.page.custom.models.idLabel': '模型 ID',
'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o',
'settings.providers.page.custom.models.nameLabel': '模型名稱',
'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o',
'settings.providers.page.custom.models.add': '新增模型',
'settings.providers.page.custom.models.remove': '移除模型',
'settings.providers.page.custom.headers.title': '標頭',
'settings.providers.page.custom.headers.description': '每次呼叫可選擇傳送的請求標頭。',
'settings.providers.page.custom.headers.keyLabel': '標頭名稱',
'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header',
'settings.providers.page.custom.headers.valueLabel': '標頭值',
'settings.providers.page.custom.headers.valuePlaceholder': 'value',
'settings.providers.page.custom.headers.add': '新增標頭',
'settings.providers.page.custom.headers.remove': '移除標頭',
'settings.providers.page.custom.actions.back': '返回',
'settings.providers.page.custom.actions.save': '儲存供應商',
'settings.providers.page.custom.actions.update': '更新提供者',
'settings.providers.page.custom.error.providerID.required': '供應商 ID 為必填',
'settings.providers.page.custom.error.providerID.format': '請使用小寫字母、數字、連字號或底線',
'settings.providers.page.custom.error.providerID.exists': '已連線具有此 ID 的供應商',
'settings.providers.page.custom.error.name.required': '顯示名稱為必填',
'settings.providers.page.custom.error.baseURL.required': '基礎 URL 為必填',
'settings.providers.page.custom.error.baseURL.format': '基礎 URL 必須以 http:// 或 https:// 開頭',
'settings.providers.page.custom.error.required': '必填',
'settings.providers.page.custom.error.duplicate': '重複',
'settings.providers.page.custom.error.apiKey.required': '需要 API 金鑰或 {env:VAR_NAME}',
'settings.providers.page.custom.authFailure.configAfterAuth': '憑證已儲存,但提供者設定未儲存。請修正錯誤後再試,或中斷連線以清除部分儲存。',
'settings.providers.page.auth.title': '驗證',
'settings.providers.page.auth.loadingMethods': '正在載入驗證方式...',
'settings.providers.page.auth.apiKeyLabel': 'API Key',
@@ -1228,6 +1278,10 @@
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}',
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '貼上授權碼',
'settings.providers.page.auth.connected': '已連線',
'settings.providers.page.auth.incomplete': '缺少憑證',
'settings.providers.page.auth.incompleteHint': '· 在聊天中使用此提供者之前,請新增 API 金鑰或 {env:VAR}',
'settings.providers.page.auth.useReconnectHint': '· 使用「重新連線」以更新憑證',
'settings.providers.page.connectionDetails.title': '連線詳情',
'settings.providers.page.connectionDetails.configuredIn': '設定來源:',
@@ -1257,6 +1311,8 @@
'settings.providers.page.actions.complete': '完成',
'settings.providers.page.actions.hide': '隱藏',
'settings.providers.page.actions.reconnect': '重新連線',
'settings.providers.page.actions.edit': '編輯',
'settings.providers.page.actions.disconnecting': '中斷連線中...',
'settings.providers.page.actions.disconnect': '中斷連線',
'settings.providers.page.actions.hideAll': '全部隱藏',
@@ -1277,6 +1333,8 @@
'settings.providers.page.toast.deviceCodeCopyFailed': '複製裝置程式碼失敗',
'settings.providers.page.toast.providerDisconnected': '供應商已中斷連線',
'settings.providers.page.toast.providerDisconnectFailed': '中斷供應商連線失敗',
'settings.providers.page.toast.customProviderSaved': '已連線 {provider}',
'settings.providers.page.toast.customProviderSaveFailed': '無法儲存自訂供應商',
'settings.mcp.page.empty.selectServer': '請從側邊欄選擇一個 MCP 伺服器',
'settings.mcp.page.empty.addNewOne': '或新增一個新的',
'settings.mcp.page.header.newServer': '新建 MCP 伺服器',
+7
View File
@@ -761,6 +761,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.providers.page.connect.title',
keywords: ['add provider', 'connect provider', 'credentials'],
},
{
id: 'providers.custom',
page: 'providers',
titleKey: 'settings.providers.page.custom.title',
descriptionKey: 'settings.providers.page.custom.description',
keywords: ['other', 'custom', 'openai-compatible', 'base url', 'api key'],
},
{
id: 'providers.auth',
page: 'providers',
+151
View File
@@ -93,6 +93,157 @@ describe('terminal transport', () => {
transport.dispose();
});
test('invalidates URL auth when the current socket closes before opening', async () => {
const socket = new FakeSocket();
let cleared = 0;
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => socket,
clearUrlAuthToken: () => { cleared += 1; },
});
const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} });
await tick();
socket.close();
await tick();
expect(cleared).toBe(1);
unsubscribe();
transport.dispose();
});
test('invalidates URL auth before retrying a pre-open socket error', async () => {
const socket = new FakeSocket();
let cleared = 0;
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => socket,
clearUrlAuthToken: () => { cleared += 1; },
});
const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} });
await tick();
socket.onerror?.();
expect(cleared).toBe(1);
unsubscribe();
transport.dispose();
});
test('does not let a cancelled opening reconnect a replacement subscription', async () => {
const sockets = [new FakeSocket(), new FakeSocket()];
let socketIndex = 0;
const replacementEvents: string[] = [];
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => sockets[socketIndex++]!,
});
const unsubscribeFirst = transport.subscribe('term-1', { onEvent: () => {} });
await tick();
unsubscribeFirst();
const unsubscribeReplacement = transport.subscribe('term-1', {
onEvent: (event) => replacementEvents.push(event.type),
});
await tick();
sockets[1]?.open();
await tick();
expect(replacementEvents).not.toContain('reconnecting');
unsubscribeReplacement();
transport.dispose();
});
test('starts a fresh reconnect sequence after every terminal has detached', async () => {
const firstEvents: number[] = [];
const replacementEvents: number[] = [];
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => { throw new Error('offline'); },
});
const unsubscribeFirst = transport.subscribe('term-1', {
onEvent: (event) => {
if (event.type === 'reconnecting' && typeof event.attempt === 'number') firstEvents.push(event.attempt);
},
});
await tick();
await tick();
expect(firstEvents).toEqual([1]);
unsubscribeFirst();
const unsubscribeReplacement = transport.subscribe('term-2', {
onEvent: (event) => {
if (event.type === 'reconnecting' && typeof event.attempt === 'number') replacementEvents.push(event.attempt);
},
});
await tick();
await tick();
expect(replacementEvents).toEqual([1]);
unsubscribeReplacement();
transport.dispose();
});
test('waits a minute before reconnecting while hidden', async () => {
const originalSetTimeout = globalThis.setTimeout;
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const delays: number[] = [];
let transport: TerminalTransport | null = null;
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
visibilityState: 'hidden',
addEventListener: () => {},
removeEventListener: () => {},
},
});
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
delays.push(Number(timeout ?? 0));
if (timeout === 0) return originalSetTimeout(handler, 0, ...args);
return 0 as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout;
try {
transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => { throw new Error('offline'); },
});
transport.subscribe('term-1', { onEvent: () => {} });
await tick();
await tick();
expect(delays).toContain(60_000);
} finally {
transport?.dispose();
globalThis.setTimeout = originalSetTimeout;
if (originalDocument) Object.defineProperty(globalThis, 'document', originalDocument);
else delete (globalThis as { document?: unknown }).document;
}
});
test('attaches a remaining same-terminal subscriber after the first one leaves', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
const unsubscribeOther = transport.subscribe('term-other', { onEvent: () => {} });
await tick();
socket.open();
await tick();
const unsubscribeFirst = transport.subscribe('term-1', { onEvent: () => {} });
const unsubscribeRemaining = transport.subscribe('term-1', { onEvent: () => {} });
unsubscribeFirst();
await tick();
expect(socket.sent.filter((message) => message.t === 'attach' && message.s === 'term-1')).toHaveLength(1);
unsubscribeRemaining();
unsubscribeOther();
transport.dispose();
});
test('releases replay projections when the last subscriber detaches', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
+79 -50
View File
@@ -3,7 +3,7 @@ import { openRuntimeWebSocket } from './relay/runtime-socket';
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
import { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url';
import { refreshRuntimeUrlAuthToken } from './runtime-auth';
import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from './runtime-auth';
import { isTerminalShell } from './terminalShell';
type Message = Record<string, unknown> & { t: string; s?: string; q?: number };
@@ -66,12 +66,12 @@ const trimProjection = (value: string): string => {
type TerminalTransportDependencies = {
refreshAuth: () => Promise<unknown>;
openSocket: () => RelayTunnelWebSocket;
clearUrlAuthToken?: () => void;
};
export class TerminalTransport {
private socket: RelayTunnelWebSocket | null = null;
private opening: Promise<void> | null = null;
private openingGeneration: number | null = null;
private subscribers = new Map<string, Set<Subscriber>>();
private projections = new Map<string, TerminalProjection>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -85,6 +85,7 @@ export class TerminalTransport {
constructor(private readonly dependencies: TerminalTransportDependencies = {
refreshAuth: refreshRuntimeUrlAuthToken,
openSocket: () => openRuntimeWebSocket(getRuntimeUrlResolver().websocket('/api/terminal/ws')),
clearUrlAuthToken: clearRuntimeUrlAuthToken,
}) {}
subscribe(sessionId: string, handlers: TerminalHandlers): () => void {
@@ -100,7 +101,13 @@ export class TerminalTransport {
handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
}
const socketWasOpen = this.socket?.readyState === SOCKET_OPEN;
this.ensureConnected().then(() => { if (first && socketWasOpen && set.has(subscriber)) this.send({ t: 'attach', v: 3, s: sessionId }); }).catch((error) => {
this.ensureConnected().then(() => {
const current = this.subscribers.get(sessionId);
if (first && socketWasOpen && current === set && current.size > 0) {
this.send({ t: 'attach', v: 3, s: sessionId });
}
}).catch((error) => {
if (!set.has(subscriber)) return;
handlers.onError?.(error, false);
this.scheduleReconnect();
});
@@ -114,6 +121,7 @@ export class TerminalTransport {
}
if (this.subscribers.size === 0) {
this.cancelReconnect();
this.failures = 0;
if (this.socket?.readyState === SOCKET_OPEN) {
// Healthy socket: hold it briefly so a tab switch can reattach to it.
this.scheduleIdleClose();
@@ -121,6 +129,7 @@ export class TerminalTransport {
}
// Nothing to reuse, so abandon any dial that is still in flight.
this.generation += 1;
this.opening = null;
this.closeSocket();
}
};
@@ -138,6 +147,7 @@ export class TerminalTransport {
dispose(): void {
this.disposed = true;
this.generation += 1;
this.opening = null;
this.subscribers.clear();
this.projections.clear();
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
@@ -155,71 +165,89 @@ export class TerminalTransport {
private async ensureConnected(): Promise<void> {
if (this.disposed) throw new Error('Terminal runtime changed');
if (this.socket?.readyState === SOCKET_OPEN) return;
if (this.opening && this.openingGeneration === this.generation) {
if (this.opening) {
await this.opening;
if (this.socket?.readyState === SOCKET_OPEN) return;
return this.ensureConnected();
}
if (this.openingGeneration !== this.generation) {
this.opening = null;
this.openingGeneration = null;
}
const generation = this.generation;
const opening = (async () => {
await this.dependencies.refreshAuth();
if (generation !== this.generation || this.disposed) throw new Error('Terminal runtime changed');
await new Promise<void>((resolve, reject) => {
let settled = false;
let pendingSocket: RelayTunnelWebSocket | null = null;
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (error) reject(error);
else resolve();
};
const timeout = setTimeout(() => {
pendingSocket?.close();
finish(new Error('Terminal connection timed out'));
}, 10_000);
try {
const socket = this.dependencies.openSocket();
pendingSocket = socket;
socket.binaryType = 'arraybuffer';
this.socket = socket;
socket.onopen = () => {
if (generation !== this.generation || this.disposed) { socket.close(); finish(new Error('Terminal runtime changed')); return; }
this.failures = 0;
this.send({ t: 'hello', v: 3 });
for (const sessionId of this.subscribers.keys()) this.send({ t: 'attach', v: 3, s: sessionId });
this.startKeepalive();
finish();
let settled = false;
let opened = false;
let authInvalidated = false;
let pendingSocket: RelayTunnelWebSocket | null = null;
const isCurrentSocket = () => (
generation === this.generation &&
!this.disposed &&
pendingSocket !== null &&
this.socket === pendingSocket
);
const invalidatePreOpenAuth = () => {
if (authInvalidated || opened || !isCurrentSocket()) return;
authInvalidated = true;
this.dependencies.clearUrlAuthToken?.();
};
socket.onmessage = (event) => void this.handleMessage(event.data);
socket.onerror = () => {
finish(new Error('Terminal WebSocket failed'));
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (error) reject(error);
else resolve();
};
const timeout = setTimeout(() => {
invalidatePreOpenAuth();
pendingSocket?.close();
finish(new Error('Terminal connection timed out'));
}, 10_000);
try {
const socket = this.dependencies.openSocket();
pendingSocket = socket;
socket.binaryType = 'arraybuffer';
this.socket = socket;
socket.onopen = () => {
if (!isCurrentSocket()) { socket.close(); finish(new Error('Terminal runtime changed')); return; }
opened = true;
this.failures = 0;
this.send({ t: 'hello', v: 3 });
for (const sessionId of this.subscribers.keys()) this.send({ t: 'attach', v: 3, s: sessionId });
this.startKeepalive();
finish();
};
socket.onmessage = (event) => void this.handleMessage(event.data);
socket.onerror = () => {
const current = isCurrentSocket();
if (current) invalidatePreOpenAuth();
finish(new Error('Terminal WebSocket failed'));
if (current && this.subscribers.size > 0) this.scheduleReconnect();
};
socket.onclose = () => {
const current = isCurrentSocket();
if (current) {
this.stopKeepalive();
// An upgrade rejected before `open` commonly means the cached
// URL-scoped auth token is stale. Retrying it reaches the 8s
// backoff cap instead of minting a fresh token.
invalidatePreOpenAuth();
}
if (this.socket === socket) this.socket = null;
finish(new Error('Terminal WebSocket closed'));
if (current && this.subscribers.size > 0) this.scheduleReconnect();
};
} catch (error) {
finish(error instanceof Error ? error : new Error('Terminal WebSocket failed'));
if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect();
};
socket.onclose = () => {
if (this.socket === socket) this.socket = null;
this.stopKeepalive();
finish(new Error('Terminal WebSocket closed'));
if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect();
};
} catch (error) {
finish(error instanceof Error ? error : new Error('Terminal WebSocket failed'));
if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect();
}
}
});
})();
this.opening = opening;
this.openingGeneration = generation;
try {
await opening;
} finally {
if (this.opening === opening) {
this.opening = null;
this.openingGeneration = null;
}
}
}
@@ -279,7 +307,7 @@ export class TerminalTransport {
if (this.reconnectTimer || this.disposed || this.subscribers.size === 0) return;
this.failures += 1;
const slow = (typeof document !== 'undefined' && document.visibilityState === 'hidden') || (typeof navigator !== 'undefined' && !navigator.onLine);
const delay = Math.min(500 * 2 ** Math.min(this.failures - 1, 10), slow ? 60_000 : 8_000);
const delay = slow ? 60_000 : Math.min(500 * 2 ** Math.min(this.failures - 1, 10), 8_000);
for (const set of this.subscribers.values()) for (const sub of set) sub.handlers.onEvent({ type: 'reconnecting', attempt: this.failures, maxAttempts: Number.POSITIVE_INFINITY });
const wake = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
@@ -304,6 +332,7 @@ export class TerminalTransport {
this.idleCloseTimer = null;
if (this.disposed || this.subscribers.size > 0) return;
this.generation += 1;
this.opening = null;
this.closeSocket();
}, IDLE_SOCKET_GRACE_MS);
}
@@ -127,6 +127,7 @@ mock.module('./useGlobalSessionsStore', () => ({
}));
mock.module('@/sync/sync-refs', () => ({
getSyncSessionDirectory: () => null,
registerSessionDirectory: (sessionID: string, directory: string) => {
registeredDirectories.push({ sessionID, directory });
},
@@ -98,9 +98,94 @@ describe('useSkillsStore directory resolution', () => {
source: 'agents',
description: 'Repository local',
group: undefined,
renamable: false,
}]);
});
test('loadSkills maps authoritative renamable from the list response', async () => {
runtimeFetchImpl = async () => new Response(JSON.stringify({
skills: [
{
name: 'managed-skill',
path: `${activeProjectPath}/.opencode/skills/managed-skill/SKILL.md`,
scope: 'project',
source: 'opencode',
renamable: true,
sources: { md: { description: 'Managed' } },
},
{
name: 'cache-skill',
path: '/home/ubuntu/.cache/opencode/skills/hash/cache-skill/SKILL.md',
scope: 'user',
source: 'opencode',
renamable: false,
sources: { md: { description: 'Cache' } },
},
],
}), {
headers: { 'Content-Type': 'application/json' },
});
expect(await useSkillsStore.getState().loadSkills()).toBe(true);
expect(useSkillsStore.getState().skills).toEqual([
{
name: 'managed-skill',
path: `${activeProjectPath}/.opencode/skills/managed-skill/SKILL.md`,
scope: 'project',
source: 'opencode',
description: 'Managed',
group: undefined,
renamable: true,
},
{
name: 'cache-skill',
path: '/home/ubuntu/.cache/opencode/skills/hash/cache-skill/SKILL.md',
scope: 'user',
source: 'opencode',
description: 'Cache',
group: 'hash',
renamable: false,
},
]);
});
test('renameSkill uses getRequestDirectory query and x-opencode-directory header', async () => {
runtimeFetchImpl = async (_url, init) => {
if (init?.method === 'PATCH') {
return new Response(JSON.stringify({
success: true,
requiresReload: false,
}), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({
skills: [{
name: 'new-skill',
path: `${activeProjectPath}/.opencode/skills/new-skill/SKILL.md`,
scope: 'project',
source: 'opencode',
renamable: true,
sources: { md: { description: 'Renamed' } },
}],
}), {
headers: { 'Content-Type': 'application/json' },
});
};
const renamed = await useSkillsStore.getState().renameSkill('old-skill', 'new-skill');
expect(renamed).toBe(true);
const renameCall = runtimeFetchCalls.find((call) => String(call.url).includes('/api/config/skills/old-skill'));
expect(renameCall).toBeTruthy();
expect(renameCall?.url).toContain(`directory=${encodeURIComponent(activeProjectPath)}`);
const headers = new Headers(renameCall?.headers);
expect(headers.get('content-type')).toBe('application/json');
expect(headers.get('x-opencode-directory')).toBe(activeProjectPath);
});
test('invalidateSkillsLoadCache() with no argument clears the active-project cache key used by loadSkills', async () => {
expect(await useSkillsStore.getState().loadSkills()).toBe(true);
expect(runtimeFetchCalls.length).toBe(1);
+52
View File
@@ -77,6 +77,8 @@ export interface DiscoveredSkill {
description?: string;
/** Domain folder parsed from file path, e.g. "automation-ai", "lark-ecosystem" */
group?: string;
/** Authoritative server flag: skill lives under a managed root and can be renamed in place. */
renamable?: boolean;
}
/** Parse the domain group folder from a skill file path.
@@ -100,6 +102,7 @@ interface RawSkillResponse {
path: string;
scope?: SkillScope;
source?: SkillSource;
renamable?: boolean;
sources?: {
md?: {
description?: string;
@@ -150,6 +153,7 @@ interface SkillsStore {
getSkillDetail: (name: string) => Promise<SkillDetail | null>;
createSkill: (config: SkillConfig) => Promise<boolean>;
updateSkill: (name: string, config: Partial<SkillConfig>) => Promise<boolean>;
renameSkill: (name: string, newName: string) => Promise<boolean>;
deleteSkill: (name: string) => Promise<boolean>;
getSkillByName: (name: string) => DiscoveredSkill | undefined;
@@ -284,6 +288,7 @@ export const useSkillsStore = create<SkillsStore>()(
source: s.source ?? 'opencode',
description: s.sources?.md?.description || '',
group: parseSkillGroup(s.path),
renamable: s.renamable === true,
}));
set({ skills: configSkills, isLoading: false });
@@ -448,6 +453,53 @@ export const useSkillsStore = create<SkillsStore>()(
}
},
renameSkill: async (name: string, newName: string) => {
startConfigUpdate("Renaming skill...");
let requiresReload = false;
try {
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify({ renameTo: newName }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to rename skill';
throw new Error(message);
}
const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(directory);
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
deleteSkill: async (name: string) => {
try {
const directory = getRequestDirectory();
+25
View File
@@ -197,6 +197,30 @@ Directory stores also own session-keyed sidecar notification channels for permis
Message sidecar consumers also filter targeted updates by purpose before notifying React. Suspended live-tail text/reasoning changes do not rebuild visible message records, but structural Task session identity changes bypass suspension so a parent can link a newly created subagent immediately. Assistant-only part changes do not rebuild user input history, and targeted updates that preserve authoritative part buckets do not recheck a session that is already renderable. Message replacements, removed final part buckets, and conservative resets always notify.
## Session directory resolution
`session-directory-resolution.ts` owns the precedence used to answer "which directory does this session belong to". Every send, message fetch, message-queue key, and send-confirmation lookup is routed by that answer, so a wrong value is not a display problem: the prompt is posted against a directory that does not own the session, the request is rejected, and the optimistic message is rolled back with no visible error.
Precedence, highest authority first:
The discriminator is whether the server confirmed the path, not whether the value is local or synced.
| Source | Meaning |
|---|---|
| `authoritative` | The child store that actually holds the session, then its own record |
| `selected` | Server-confirmed directory captured at selection; a guessed one is never passed |
| `attachment` | Worktree attachment recorded by this client; the *requested* path |
| `worktree-metadata` | Worktree captured when the session was created in one; the *requested* path |
| `remembered` | Per-runtime directory persisted across restarts |
Rules:
1. `getSyncSessionDirectory()` is the authoritative session→directory mapping: a session lives in exactly the child store for its directory, whether or not the server populated `session.directory`. `null` means "not indexed yet", never "no directory".
2. `attachment` and `worktreeMetadata` hold the worktree path this client asked for, before the server canonicalized it. They are a hint for a session sync has not indexed yet, never a correction of a confirmed directory — otherwise a stale local path re-creates the very mismatch this precedence exists to prevent.
3. Never persist or rank a guessed directory. `selectSession` may fall back to the active directory to keep routing usable, but that value is not written to runtime memory, not written to the last-active snapshot, and not passed as `selected` — a persisted guess outlives the race that produced it and survives reloads and restarts.
4. Components must not read `currentSessionDirectory` to build request or queue keys; use `getDirectoryForSession()` so every consumer resolves identically.
5. A disagreement between sources is logged once per session, and `__opencodeDebug.diagnoseSessionDirectory()` reports every source in precedence order.
## Session action rules
Session actions live in `session-actions.ts` and are the canonical place for SDK-calling session mutations that affect global session lists.
@@ -207,6 +231,7 @@ Rules:
2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct.
3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls.
4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected.
5. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
Examples of global-store updates performed in `session-actions.ts`:
@@ -42,6 +42,7 @@ mock.module("../session-ui-store", () => ({
}))
mock.module("../sync-refs", () => ({
getSyncSessionDirectory: () => null,
registerSessionDirectory: (sessionID: string, directory: string) => {
registerSessionDirectoryCalls.push({ sessionID, directory })
},
@@ -4,11 +4,16 @@ import { togglePermissionAutoAccept } from "../../components/chat/permissionAuto
const storage = new Map<string, string>()
const createSessionCalls: Array<{ title?: string; directory: string | null; parentID: string | null; metadata?: unknown }> = []
const permissionAutoAcceptCalls: Array<[string, boolean]> = []
let createdSessionDirectory: string | undefined
const getMockCalls = (fn: unknown): unknown[][] => ((fn as { mock?: { calls: unknown[][] } }).mock?.calls ?? [])
mock.module("zustand", () => ({
create: () => (initializer: (set: (patch: unknown | ((state: unknown) => unknown)) => void, get: () => unknown) => Record<string, unknown>) => {
create: () => (initializer: (
set: (patch: unknown | ((state: unknown) => unknown)) => void,
get: () => unknown,
api?: unknown,
) => Record<string, unknown>) => {
let state: Record<string, unknown>
const get = () => state
const set = (patch: unknown | ((current: Record<string, unknown>) => unknown)) => {
@@ -16,7 +21,12 @@ mock.module("zustand", () => ({
state = next && typeof next === "object" ? { ...state, ...(next as Record<string, unknown>) } : state
}
state = initializer(set, get)
state = initializer(set, get, {
setState: set,
getState: get,
getInitialState: get,
subscribe: () => () => undefined,
} as never)
const store = ((selector?: (current: Record<string, unknown>) => unknown) => (
typeof selector === "function" ? selector(state) : state
@@ -53,6 +63,11 @@ const deferredStorage: Storage = {
mock.module("@/stores/utils/safeStorage", () => ({
getDeferredSafeStorage: () => deferredStorage,
createDeferredSafeJSONStorage: () => ({
getItem: async () => null,
setItem: async () => undefined,
removeItem: async () => undefined,
}),
}))
mock.module("@/lib/opencode/client", () => ({
@@ -224,15 +239,18 @@ mock.module("../sync-refs", () => ({
getSyncMessages: () => [],
getSyncParts: () => [],
getAllSyncSessions: () => [],
getSyncSessionDirectory: () => null,
}))
mock.module("../session-actions", () => ({
createSession: mock(async (title: string | undefined, directory: string | null, parentID: string | null, metadata?: unknown) => {
createSessionCalls.push({ title, directory, parentID, metadata })
return { id: "ses_issue_2039", directory }
return { id: "ses_issue_2039", directory: createdSessionDirectory ?? directory }
}),
deleteSession: mock(async () => true),
deleteSessions: mock(async () => ({ deletedIds: [], failedIds: [] })),
archiveSession: mock(async () => true),
archiveSessions: mock(async () => ({ archivedIds: [], failedIds: [] })),
updateSessionTitle: mock(async () => undefined),
shareSession: mock(async () => undefined),
unshareSession: mock(async () => undefined),
@@ -242,6 +260,9 @@ mock.module("../session-actions", () => ({
unrevertSession: mock(async () => undefined),
forkFromMessage: mock(async () => undefined),
fetchMessagesForSession: mock(async () => undefined),
getSessionLastAssistantModel: () => null,
patchSessionMetadata: mock(async () => undefined),
abortCurrentOperation: mock(async () => undefined),
}))
const { materializeOpenDraftSession, useSessionUIStore } = await import("../session-ui-store")
@@ -298,6 +319,7 @@ describe("issue 2039 draft auto-accept", () => {
storage.clear()
createSessionCalls.length = 0
permissionAutoAcceptCalls.length = 0
createdSessionDirectory = undefined
useSessionUIStore.setState({
currentSessionId: null,
@@ -348,4 +370,45 @@ describe("issue 2039 draft auto-accept", () => {
expect(createSessionCalls).toHaveLength(0)
expect(permissionAutoAcceptCalls).toHaveLength(0)
})
test("uses the server-authoritative directory after worktree session creation", async () => {
createdSessionDirectory = "/canonical/worktree"
useSessionUIStore.getState().openNewSessionDraft({
directoryOverride: "/requested/worktree",
})
const result = await materializeOpenDraftSession({
providerID: "provider",
modelID: "model",
})
expect(createSessionCalls[0]?.directory).toBe("/requested/worktree")
expect(result?.directory).toBe("/canonical/worktree")
expect(useSessionUIStore.getState().currentSessionDirectory).toBe("/canonical/worktree")
})
test("routes the session by the canonical directory, not the requested worktree path", async () => {
createdSessionDirectory = "/canonical/worktree"
useSessionUIStore.getState().openNewSessionDraft({
directoryOverride: "/requested/worktree",
})
const created = await materializeOpenDraftSession({
providerID: "provider",
modelID: "model",
})
const sessionId = created?.sessionId ?? ""
// The worktree attachment still holds the path this client asked for. The
// directory every send, queue key, and confirmation lookup is routed by
// must be the canonical one the server returned.
useSessionUIStore.getState().setWorktreeMetadata(sessionId, {
path: "/requested/worktree",
projectDirectory: "/repo",
branch: "feature",
label: "feature",
})
expect(useSessionUIStore.getState().getDirectoryForSession(sessionId)).toBe("/canonical/worktree")
})
})
+49
View File
@@ -0,0 +1,49 @@
/**
* Recent prompt-send failures, kept in memory for diagnostics.
*
* A rejected send rolls the optimistic message back and, for transport-level
* failures, the composer stays silent by design. That makes a misrouted or
* refused prompt indistinguishable from "nothing happened" the user has
* nothing to report beyond "it disappeared".
*
* This buffer gives the failure somewhere to live until someone asks for it,
* via the About dialog's diagnostics report or `__opencodeDebug`. It is
* in-memory only: never persisted, never sent anywhere, and dropped on reload.
*/
const MAX_RECORDED_SEND_FAILURES = 20
const MAX_REASON_LENGTH = 200
export type SendFailureRecord = {
at: number
sessionId: string
messageId: string
/** Directory the prompt was routed to — the value under suspicion. */
directory: string | null
/** HTTP status, or null for a transport failure with no response. */
status: number | null
/** Whether the send may still have been accepted server-side. */
ambiguous: boolean
/** Whether a confirmation refetch ran and failed to find the message. */
confirmationChecked: boolean
reason: string
}
const records: SendFailureRecord[] = []
export function recordSendFailure(record: Omit<SendFailureRecord, 'at' | 'reason'> & { reason: string }): void {
records.push({
...record,
reason: record.reason.slice(0, MAX_REASON_LENGTH),
at: Date.now(),
})
if (records.length > MAX_RECORDED_SEND_FAILURES) {
records.splice(0, records.length - MAX_RECORDED_SEND_FAILURES)
}
}
/** Newest first. */
export function getRecentSendFailures(): SendFailureRecord[] {
return [...records].reverse()
}
@@ -245,6 +245,7 @@ mock.module("./session-deletion-cleanup", () => ({
}))
mock.module("./sync-refs", () => ({
getSyncSessionDirectory: () => null,
registerSessionDirectory: (sessionID: string, directory: string) => {
registeredSessionDirectories.push({ sessionID, directory })
},
+22 -1
View File
@@ -13,6 +13,7 @@ import { opencodeClient } from "@/lib/opencode/client"
import { mergeSessionDirectoryMetadata, resolveGlobalSessionDirectory, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import { useConfigStore } from "@/stores/useConfigStore"
import { registerSessionDirectory } from "./sync-refs"
import { recordSendFailure } from "./send-failure-log"
import { isSyntheticPart } from "@/lib/messages/synthetic"
import { materializeSessionSnapshots } from "./materialization"
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
@@ -1176,7 +1177,9 @@ export async function optimisticSend(input: {
try {
await input.send(messageID)
} catch (error) {
const acceptedRecords = isAmbiguousSendFailure(error)
const status = getErrorStatus(error)
const ambiguousFailure = isAmbiguousSendFailure(error)
const acceptedRecords = ambiguousFailure
? await fetchRecentSendConfirmationRecords(input.sessionId, messageID, targetDirectory)
: null
@@ -1190,6 +1193,24 @@ export async function optimisticSend(input: {
return
}
// The rollback below makes the user's message disappear with no other
// trace, and the composer intentionally stays silent for transport-level
// failures. Record the failure so the About dialog's diagnostics report can
// answer "it disappeared and nothing happened" with an actual cause.
// `reason` is truncated by the recorder: a rejected send echoes the
// provider/OpenCode response body, which this log has no reason to keep.
const failureRecord = {
sessionId: input.sessionId,
messageId: messageID,
directory: targetDirectory ?? null,
status,
ambiguous: ambiguousFailure,
confirmationChecked: ambiguousFailure,
reason: error instanceof Error ? error.message : String(error),
}
recordSendFailure(failureRecord)
console.warn("[session-actions] prompt send rejected; rolling back optimistic message", failureRecord)
// Rollback via optimistic infrastructure
_optimisticRemove({
sessionID: input.sessionId,
@@ -0,0 +1,131 @@
import { describe, expect, test } from 'bun:test';
import {
describeSessionDirectorySources,
resolveSessionDirectoryFromSources,
} from './session-directory-resolution';
const WORKTREE = '/repo/.worktrees/feature';
const MAIN = '/repo';
describe('resolveSessionDirectoryFromSources', () => {
test('authoritative directory beats a selection-time fallback', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: WORKTREE,
selected: MAIN,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.source).toBe('authoritative');
expect(resolution.conflict).toEqual({ source: 'selected', directory: MAIN });
});
test('authoritative directory beats a directory persisted across restarts', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: WORKTREE,
remembered: MAIN,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.conflict).toEqual({ source: 'remembered', directory: MAIN });
});
test('the indexed directory outranks a locally requested worktree path', () => {
// attachment/worktreeMetadata hold the path this client asked for, before
// the server canonicalized it. Letting them win would route prompts to a
// directory that no child store owns.
const resolution = resolveSessionDirectoryFromSources({
attachment: '/requested/worktree',
worktreeMetadata: '/requested/worktree',
authoritative: WORKTREE,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.source).toBe('authoritative');
expect(resolution.conflict).toEqual({ source: 'attachment', directory: '/requested/worktree' });
});
test('a worktree attachment is used while the session is not indexed yet', () => {
// A guessed selection is not passed as `selected` at all, so the worktree
// assignment is the best available value during the bootstrap race.
const resolution = resolveSessionDirectoryFromSources({
authoritative: null,
selected: null,
attachment: WORKTREE,
remembered: MAIN,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.source).toBe('attachment');
expect(resolution.conflict).toEqual({ source: 'remembered', directory: MAIN });
});
test('a server-confirmed selection outranks the requested worktree path', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: null,
selected: '/canonical/worktree',
attachment: '/requested/worktree',
worktreeMetadata: '/requested/worktree',
});
expect(resolution.directory).toBe('/canonical/worktree');
expect(resolution.source).toBe('selected');
});
test('falls back to the selection hint while the session is not indexed yet', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: null,
selected: WORKTREE,
});
expect(resolution.directory).toBe(WORKTREE);
expect(resolution.source).toBe('selected');
expect(resolution.conflict).toBeNull();
});
test('agreeing sources report no conflict', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: WORKTREE,
selected: WORKTREE,
remembered: WORKTREE,
});
expect(resolution.conflict).toBeNull();
});
test('reports the first disagreeing source, not the last', () => {
const resolution = resolveSessionDirectoryFromSources({
authoritative: WORKTREE,
selected: MAIN,
remembered: '/somewhere/else',
});
expect(resolution.conflict).toEqual({ source: 'selected', directory: MAIN });
});
test('treats missing and blank values as unknown, never as a directory', () => {
const resolution = resolveSessionDirectoryFromSources({
attachment: null,
worktreeMetadata: ' ',
authoritative: undefined,
selected: '',
});
expect(resolution.directory).toBeNull();
expect(resolution.source).toBe('none');
expect(resolution.conflict).toBeNull();
});
});
describe('describeSessionDirectorySources', () => {
test('lists populated sources in precedence order', () => {
expect(describeSessionDirectorySources({
remembered: MAIN,
authoritative: WORKTREE,
selected: '',
})).toEqual([
{ source: 'authoritative', directory: WORKTREE },
{ source: 'remembered', directory: MAIN },
]);
});
});
@@ -0,0 +1,137 @@
/**
* Session directory resolution precedence.
*
* A session's directory decides which OpenCode project every send, message
* fetch, queue key, and confirmation lookup is routed to. Getting it wrong is
* not a cosmetic problem: the prompt is posted against a directory that does
* not own the session, the send is rejected, and the optimistic message is
* rolled back with no visible error.
*
* The precedence below is deliberate and ordered by authority, not by
* convenience:
*
* The ordering discriminator is **whether the server confirmed the path**, not
* whether the value is local or synced:
*
* 1. `authoritative` the child store that actually holds the session, then
* the session's own record. Server-backed truth for an indexed session.
* 2. `selected` the directory captured when the session was selected, but
* only when it came from a server response (the directory `createSession`
* returned, which may be a canonicalized form of what was requested). A
* selection that fell back to the active directory is a guess and is not
* passed here at all.
* 3. `attachment` / `worktreeMetadata` the worktree this client assigned to
* the session. Both hold the *requested* path, before the server had a
* chance to canonicalize it, so they are a hint for a session sync has not
* indexed yet, never a correction of a confirmed one.
* 4. `remembered` the per-runtime directory persisted across restarts. Last
* resort: it survives reloads, so a value written from a startup fallback
* would otherwise outlive the race that produced it.
*
* Routing a prompt by an unconfirmed path posts it against a directory that
* does not own the session, and the send is rejected. Moves need no exception:
* a session move updates the owning child store before any client-side value.
*/
export type SessionDirectorySource =
| 'authoritative'
| 'selected'
| 'attachment'
| 'worktree-metadata'
| 'remembered'
| 'none'
export type SessionDirectorySources = {
/** Directory of the child store that holds the session, or its own record. */
authoritative?: string | null
/** Server-confirmed directory captured at selection. Never a guessed one. */
selected?: string | null
/** Worktree attachment recorded for this session; the requested path. */
attachment?: string | null
/** Worktree metadata captured when the session was created in a worktree. */
worktreeMetadata?: string | null
/** Directory persisted for this runtime; may outlive the race that wrote it. */
remembered?: string | null
}
export type SessionDirectoryResolution = {
directory: string | null
source: SessionDirectorySource
/**
* Set when a lower-priority source disagrees with the winning one. This is
* the signature of the stale-directory bug: a persisted or selection-time
* fallback pointing at the parent repository while the session lives in a
* worktree.
*/
conflict: { source: SessionDirectorySource; directory: string } | null
}
const RESOLUTION_ORDER: ReadonlyArray<Exclude<SessionDirectorySource, 'none'>> = [
'authoritative',
'selected',
'attachment',
'worktree-metadata',
'remembered',
]
const readSource = (
sources: SessionDirectorySources,
source: Exclude<SessionDirectorySource, 'none'>,
): string | null => {
const value = source === 'attachment'
? sources.attachment
: source === 'worktree-metadata'
? sources.worktreeMetadata
: source === 'authoritative'
? sources.authoritative
: source === 'selected'
? sources.selected
: sources.remembered
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
/**
* Resolve a session directory from every known source, reporting which source
* won and whether a weaker source disagreed.
*
* Callers normalize paths before passing them in; this module only orders
* authority and never rewrites a path.
*/
export const resolveSessionDirectoryFromSources = (
sources: SessionDirectorySources,
): SessionDirectoryResolution => {
let winner: { source: SessionDirectorySource; directory: string } | null = null
let conflict: { source: SessionDirectorySource; directory: string } | null = null
for (const source of RESOLUTION_ORDER) {
const directory = readSource(sources, source)
if (!directory) continue
if (!winner) {
winner = { source, directory }
continue
}
if (!conflict && directory !== winner.directory) {
conflict = { source, directory }
}
}
if (!winner) {
return { directory: null, source: 'none', conflict: null }
}
return { directory: winner.directory, source: winner.source, conflict }
}
/** Every source that carries a value, in precedence order. For diagnostics. */
export const describeSessionDirectorySources = (
sources: SessionDirectorySources,
): Array<{ source: SessionDirectorySource; directory: string }> => {
const described: Array<{ source: SessionDirectorySource; directory: string }> = []
for (const source of RESOLUTION_ORDER) {
const directory = readSource(sources, source)
if (directory) described.push({ source, directory })
}
return described
}
+139 -27
View File
@@ -40,7 +40,13 @@ import {
getSyncMessages,
getSyncParts,
getDirectoryState,
getSyncSessionDirectory,
} from "./sync-refs"
import {
resolveSessionDirectoryFromSources,
type SessionDirectoryResolution,
type SessionDirectorySources,
} from "./session-directory-resolution"
import { markSessionViewed } from "./notification-store"
import { setActiveSession } from "./sync-context"
import {
@@ -73,7 +79,7 @@ import { useSessionWorktreeStore } from "./session-worktree-store"
import { getAttachedSessionDirectory } from "./session-worktree-contract"
import { setSessionOpener } from "./session-navigation"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { clearLastActiveSession, persistLastActiveSession } from "./last-session-cache"
import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession } from "./last-session-cache"
import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache"
import { rememberRuntimeLiveStatus } from "./runtime-live-memory"
@@ -394,23 +400,108 @@ const getAttachmentForSession = (sessionId: string | null | undefined): SessionW
return useSessionWorktreeStore.getState().getAttachment(sessionId)
}
/**
* Authoritative directory for a session: the child store that holds it, and
* only then the session record's own fields. `null` means "not indexed yet",
* never "no directory" callers must fall back rather than treat it as empty.
*/
const getAuthoritativeSessionDirectory = (sessionId: string): string | null => {
const owningDirectory = getSyncSessionDirectory(sessionId)
if (owningDirectory) return normalizePath(owningDirectory)
const target = getAllSyncSessions().find((s) => s.id === sessionId)
return target ? resolveDirectoryKey(target) : null
}
/**
* Directory remembered for a session in this runtime, plus the one persisted
* across restarts. Exported for diagnostics: a stale persisted directory is the
* hardest source to observe and the one that survives reloads, so a report that
* cannot show it cannot rule it out.
*/
export const getRememberedSessionDirectory = (sessionId: string): {
runtime: string | null
persisted: string | null
} => {
const key = runtimeMemoryKey()
const runtimeMemory = runtimeSessionMemory.get(key)
const persisted = readLastActiveSession(key)
return {
runtime: runtimeMemory?.sessionId === sessionId ? normalizePath(runtimeMemory.directory) : null,
persisted: persisted?.sessionId === sessionId ? normalizePath(persisted.directory) : null,
}
}
/**
* Session whose `currentSessionDirectory` is only the active directory, used
* because the session's own directory was not known at selection time. Such a
* value must never outrank a worktree assignment or reach persistence it is
* a guess, not a selection.
*/
let guessedSelectionSessionId: string | null = null
const collectSessionDirectorySources = (
sessionId: string,
getWtMeta: (id: string) => WorktreeMetadata | undefined,
selected: string | null,
): SessionDirectorySources => ({
authoritative: getAuthoritativeSessionDirectory(sessionId),
selected: sessionId === guessedSelectionSessionId ? null : normalizePath(selected),
attachment: getAttachedSessionDirectory(getAttachmentForSession(sessionId)),
worktreeMetadata: normalizePath(getWtMeta(sessionId)?.path ?? null),
remembered: getRememberedSessionDirectory(sessionId).runtime,
})
/**
* Conflicts already warned about, so a stale directory logs once instead of on
* every keystroke. Keyed by runtime *and* the exact pair of directories: the
* same session ID means a different thing in another runtime, and a conflict
* that reappears after being resolved is news worth logging again. Bounded so
* a long-lived session cannot grow it without limit.
*/
const reportedDirectoryConflicts = new Set<string>()
const MAX_REPORTED_DIRECTORY_CONFLICTS = 200
const reportSessionDirectoryConflict = (
sessionId: string,
resolution: SessionDirectoryResolution,
): void => {
if (!resolution.conflict) return
const conflictKey = JSON.stringify([
runtimeMemoryKey(),
sessionId,
resolution.directory,
resolution.conflict.source,
resolution.conflict.directory,
])
if (reportedDirectoryConflicts.has(conflictKey)) return
if (reportedDirectoryConflicts.size >= MAX_REPORTED_DIRECTORY_CONFLICTS) {
reportedDirectoryConflicts.clear()
}
reportedDirectoryConflicts.add(conflictKey)
console.warn(
"[session-directory] session directory sources disagree; using the higher-authority one. "
+ "Run __opencodeDebug.diagnoseSessionDirectory() for the full picture.",
{
sessionId,
using: resolution.source,
directory: resolution.directory,
conflictingSource: resolution.conflict.source,
conflictingDirectory: resolution.conflict.directory,
},
)
}
const resolveSessionDirectory = (
sessionId: string | null | undefined,
getWtMeta: (id: string) => WorktreeMetadata | undefined,
selected: string | null = null,
): string | null => {
if (!sessionId) return null
const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId))
if (attachmentDirectory) return attachmentDirectory
const metaPath = getWtMeta(sessionId)?.path
if (typeof metaPath === "string" && metaPath.trim().length > 0) return normalizePath(metaPath)
const runtimeMemory = runtimeSessionMemory.get(runtimeMemoryKey())
if (runtimeMemory?.sessionId === sessionId && runtimeMemory.directory) {
return normalizePath(runtimeMemory.directory)
}
const sessions = getAllSyncSessions()
const target = sessions.find((s) => s.id === sessionId)
if (!target) return null
return resolveDirectoryKey(target)
const resolution = resolveSessionDirectoryFromSources(
collectSessionDirectorySources(sessionId, getWtMeta, selected),
)
reportSessionDirectoryConflict(sessionId, resolution)
return resolution.directory
}
const activateConfigForDirectory = async (directory: string | null | undefined): Promise<void> => {
@@ -504,13 +595,18 @@ export async function materializeOpenDraftSession(selection: {
const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null)
if (!created?.id) throw new Error("Failed to create session")
// The server response is authoritative. It may canonicalize a requested
// worktree path (for example through a symlink or platform path casing).
// Sending with the pre-canonical draft path can target a different
// directory scope than the session that was just created.
const createdDirectory = normalizePath(created.directory ?? draftDirectoryOverride ?? null)
persistDraftTarget({
projectId: draftProjectId,
directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null),
directory: createdDirectory,
})
const draftSyntheticParts = draft.syntheticParts
const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null)
const configState = useConfigStore.getState()
void activateConfigForDirectory(createdDirectory).catch((error) => {
console.warn("Failed to activate directory after creating session:", error)
@@ -604,7 +700,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
(sid) => get().worktreeMetadata.get(sid),
)
const fallbackDir = opencodeClient.getDirectory() ?? directoryState.currentDirectory ?? null
const resolvedDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir ?? fallbackDir
const knownDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir
const resolvedDir = knownDir ?? fallbackDir
// `fallbackDir` is the active directory, not this session's directory. It
// keeps routing usable while the owning directory store bootstraps, but it
// must never be remembered: a persisted guess outlives the race that
// produced it and survives reloads and restarts.
const isGuessedDir = knownDir === null
const projectsState = useProjectsStore.getState()
const sessionProject = resolvedDir
? resolveProjectForSessionDirectory(
@@ -617,12 +719,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// Set the directory together with the session id so chat hooks read the
// same child store that send/SSE events will update during startup races.
set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null })
writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null })
guessedSelectionSessionId = isGuessedDir && id ? id : null
const rememberedDir = isGuessedDir ? null : resolvedDir ?? null
writeRuntimeSessionMemory(key, { sessionId: id, directory: rememberedDir })
// Keep the last NON-null session per runtime across app restarts (cold
// mobile launches reopen it after the instance reconnects). Going back to
// a draft intentionally does not erase it.
if (id) {
persistLastActiveSession(key, { sessionId: id, directory: resolvedDir ?? null })
persistLastActiveSession(key, { sessionId: id, directory: rememberedDir })
}
// Kick off the message fetch on the same tick, before React commits the
@@ -1560,16 +1664,19 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
},
getDirectoryForSession: (sessionId) => {
if (sessionId === get().currentSessionId && get().currentSessionDirectory) {
return get().currentSessionDirectory
}
const resolved = resolveSessionDirectory(sessionId, (sid) => get().worktreeMetadata.get(sid))
// The selection-time directory participates in resolution, it does not
// short-circuit it. For a worktree session selected before its directory
// store finished bootstrapping, that value is a startup fallback pointing
// at the parent repository; letting it win would route every send, queue
// key, and send-confirmation lookup to a directory that does not own the
// session.
const selected = sessionId === get().currentSessionId ? get().currentSessionDirectory : null
const resolved = resolveSessionDirectory(
sessionId,
(sid) => get().worktreeMetadata.get(sid),
selected,
)
if (resolved) return resolved
const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId))
if (attachmentDirectory) return attachmentDirectory
const sessions = getAllSyncSessions()
const session = sessions.find((s) => s.id === sessionId)
if (session) return resolveDirectoryKey(session)
const globalStore = useGlobalSessionsStore.getState()
const globalSession = [...globalStore.activeSessions, ...globalStore.archivedSessions]
.find((s) => s.id === sessionId)
@@ -1634,6 +1741,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
setSessionDirectory: (sessionId, directory) => {
const normalized = normalizePath(directory)
// Callers set this from a confirmed destination (a completed move, a
// created worktree), so the selection is no longer a guess.
if (sessionId === guessedSelectionSessionId) {
guessedSelectionSessionId = null
}
if (sessionId === get().currentSessionId) {
set({ currentSessionDirectory: normalized })
writeRuntimeSessionMemory(runtimeMemoryKey(), { sessionId, directory: normalized })
+20
View File
@@ -17,6 +17,7 @@ const configListeners = new Set<(directory: string, config: Config) => void>()
let cachedSessionManager: ChildStoreManager | null = null
let cachedSessionSlices = new Map<string, State["session"]>()
let cachedSessionsById = new Map<string, State["session"][number]>()
let cachedSessionDirectoryById = new Map<string, string>()
export function setSyncRefs(
_sdk: OpencodeClient,
@@ -29,6 +30,7 @@ export function setSyncRefs(
cachedSessionManager = null
cachedSessionSlices = new Map()
cachedSessionsById = new Map()
cachedSessionDirectoryById = new Map()
}
_directory = directory
if (registerSessionDirectory) {
@@ -103,20 +105,38 @@ export function getAllSyncSessionMap(): ReadonlyMap<string, State["session"][num
const nextSlices = new Map<string, State["session"]>()
const nextSessionsById = new Map<string, State["session"][number]>()
const nextDirectoriesById = new Map<string, string>()
for (const [directory, store] of stores.children) {
const sessions = store.getState().session
nextSlices.set(directory, sessions)
for (const session of sessions) {
if (!session?.id) continue
nextSessionsById.set(session.id, session)
nextDirectoriesById.set(session.id, directory)
}
}
cachedSessionManager = stores
cachedSessionSlices = nextSlices
cachedSessionsById = nextSessionsById
cachedSessionDirectoryById = nextDirectoriesById
return cachedSessionsById
}
/**
* Directory of the child store that actually holds this session.
*
* This is the authoritative sessiondirectory mapping: a session is present in
* exactly the store for the directory it belongs to, regardless of whether the
* server populated `session.directory` on the record itself. Returns `null`
* when no initialized child store contains the session, which means "unknown",
* never "no directory".
*/
export function getSyncSessionDirectory(sessionId: string): string | null {
if (!sessionId) return null
getAllSyncSessionMap()
return cachedSessionDirectoryById.get(sessionId) ?? null
}
/** Read messages for a session from current directory's child store */
export function getSyncMessages(sessionId: string, directory?: string) {
return getDirectoryState(directory)?.message[sessionId] ?? []