Merge branch 'openchamber:main' into github-usage-rework

This commit is contained in:
Jakub Syty
2026-08-27 10:06:53 +02:00
committed by GitHub
80 changed files with 1474 additions and 815 deletions
+18
View File
@@ -12,6 +12,7 @@ import { SettingsView } from '@/components/views/SettingsView';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
@@ -772,6 +773,23 @@ export function MobileApp({ apis }: MobileAppProps) {
};
}, [isNativeMobileApp, handleNativeResume]);
// A confirmed mid-session auth expiry (classified centrally from live 401
// traffic) runs the same seq-guarded re-probe the resume path uses: it ends
// in needs-login → the native welcome screen with the auth-expired notice.
// The shared web banner never renders on native (the session gate is not
// mounted here), so this is the only surface reacting to the signal.
React.useEffect(() => {
if (!isNativeMobileApp) return;
return useAuthSessionStore.subscribe((store, previous) => {
if (store.state === 'expired' && previous.state !== 'expired') {
handleNativeResume();
// The probe ladder owns the outcome from here; the shared store goes
// back to 'ok' so a later expiry can signal again.
useAuthSessionStore.getState().markAuthenticated();
}
});
}, [isNativeMobileApp, handleNativeResume]);
React.useEffect(() => {
registerRuntimeAPIs(apis);
return () => registerRuntimeAPIs(null);
+1 -1
View File
@@ -15,7 +15,7 @@ import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { resetStreamingState } from '@/sync/streaming';
import { useGlobalSessionStatusStore, replaceGlobalSessionStatusById } from '@/sync/global-session-status';
import { replaceGlobalSessionStatusById } from '@/sync/global-session-status';
import { resetSessionOrdering } from '@/sync/session-ordering';
import { resetSessionActivityTiming } from '@/sync/session-activity-timing';
import { syncDesktopSettings } from '@/lib/persistence';
@@ -0,0 +1,41 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
/**
* Non-blocking notice that the OpenChamber session expired mid-work. It never
* takes the screen on its own: work stays visible and interactive, and only
* the explicit "Log in" click hands control to the session gate's full login
* flow (password, passkey, desktop shell — all already there).
*/
export const AuthExpiredBanner: React.FC = () => {
const { t } = useI18n();
const authState = useAuthSessionStore((store) => store.state);
const markReauthenticating = useAuthSessionStore((store) => store.markReauthenticating);
if (authState !== 'expired') {
return null;
}
return (
// Below the header on purpose: the header row can be a window-drag region
// on desktop, where nothing under the cursor is clickable.
<div
className="pointer-events-none fixed inset-x-0 z-[200] flex justify-center px-4"
style={{ top: 'calc(var(--oc-header-height, 56px) + 8px)' }}
>
<div
role="alert"
className="oc-glass-popover oc-glass-floating pointer-events-auto flex items-center gap-3 rounded-lg px-3 py-2"
>
<Icon name="lock" className="size-4 flex-shrink-0" style={{ color: 'var(--status-error)' }} />
<span className="typography-ui-label text-foreground">{t('sessionAuth.expired.banner')}</span>
<Button size="xs" variant="outline" onClick={markReauthenticating} className="normal-case">
{t('sessionAuth.expired.loginAction')}
</Button>
</div>
</div>
);
};
@@ -12,6 +12,8 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { installAuthSessionFocusWatch, useAuthSessionStore } from '@/lib/runtime-auth-expiry';
import { AuthExpiredBanner } from './AuthExpiredBanner';
import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
@@ -351,6 +353,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
const [activePasskeyAction, setActivePasskeyAction] = React.useState<'auth' | 'register' | null>(null);
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
const hasResyncedRef = React.useRef(skipAuth);
const hasBootstrapResyncedRef = React.useRef(skipAuth);
React.useEffect(() => {
if (typeof window === 'undefined') {
@@ -557,6 +560,27 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
}
}, [skipAuth, state]);
// Mid-session expiry: the banner asks for a re-login by flipping the shared
// auth store to 'reauthenticating'; the gate answers with its own status
// check, which lands in the full 'locked' flow on a genuine 401. A
// successful login resolves the store back to 'ok'.
const authSessionState = useAuthSessionStore((store) => store.state);
React.useEffect(() => {
if (!skipAuth) installAuthSessionFocusWatch();
}, [skipAuth]);
React.useEffect(() => {
if (skipAuth) return;
if (authSessionState === 'reauthenticating') {
void checkStatusRef.current?.();
}
}, [authSessionState, skipAuth]);
React.useEffect(() => {
if (skipAuth) return;
if (state === 'authenticated' && useAuthSessionStore.getState().state !== 'ok') {
useAuthSessionStore.getState().markAuthenticated();
}
}, [skipAuth, state]);
React.useEffect(() => {
if (state === 'locked' && passwordInputRef.current) {
passwordInputRef.current.focus();
@@ -570,10 +594,18 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
}
if (state === 'authenticated' && !hasResyncedRef.current) {
hasResyncedRef.current = true;
// First authentication of this page load is bootstrap: adopt the
// persisted workspace pointers. A re-login after mid-session expiry is
// not — this window already has its own workspace, and the shared
// settings document may carry another window's pointers.
const isBootstrapResync = !hasBootstrapResyncedRef.current;
hasBootstrapResyncedRef.current = true;
void (async () => {
await initializeAppearancePreferences();
await syncDesktopSettings();
await applyPersistedDirectoryPreferences();
await syncDesktopSettings({ adoptWorkspace: isBootstrapResync });
if (isBootstrapResync) {
await applyPersistedDirectoryPreferences();
}
})();
}
}, [skipAuth, state]);
@@ -983,5 +1015,10 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
);
}
return <>{children}</>;
return (
<>
{skipAuth ? null : <AuthExpiredBanner />}
{children}
</>
);
};
@@ -18,6 +18,7 @@ import { StatusRowContainer } from './StatusRowContainer';
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
import { useScrollShadow } from '@/components/ui/useScrollShadow';
import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll';
import { useChatTimelineController } from './hooks/useChatTimelineController';
@@ -645,6 +646,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
suspendPartUpdatesForMessageId: streamingMessageId,
});
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
const authSessionExpired = useAuthSessionStore((store) => store.state !== 'ok');
const wasAuthExpiredRef = React.useRef(false);
const sessionMessageLoadState = useSessionMessageLoadState(
currentSessionId ?? '',
effectiveSessionDirectory,
@@ -1170,6 +1173,23 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
}, [currentSessionId, effectiveSessionDirectory, messagesEnabled, sync]);
// A load that failed while the session was expired retries itself the
// moment the re-login lands — the error screen should never outlive its
// cause.
React.useEffect(() => {
if (authSessionExpired) {
wasAuthExpiredRef.current = true;
return;
}
if (wasAuthExpiredRef.current) {
wasAuthExpiredRef.current = false;
if (sessionMessageLoadState.status === 'error') {
retrySessionLoad();
}
}
}, [authSessionExpired, retrySessionLoad, sessionMessageLoadState.status]);
React.useEffect(() => {
if (!active || !currentSessionId) return;
if (lastScrolledSessionKeyRef.current === currentSessionKey) return;
@@ -1298,10 +1318,20 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
<Icon name="error-warning" className="size-4" />
</div>
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
<p className="typography-meta mt-1 text-muted-foreground">
{authSessionExpired
? t('chat.container.sessionLoadError.authDescription')
: t('chat.container.sessionLoadError.description')}
</p>
{authSessionExpired ? (
<Button variant="outline" size="sm" className="mt-4" onClick={() => useAuthSessionStore.getState().markReauthenticating()}>
{t('sessionAuth.expired.loginAction')}
</Button>
) : (
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
)}
</div>
</div>
);
+36 -45
View File
@@ -78,6 +78,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { usePermissionStore } from '@/stores/permissionStore';
import { togglePermissionAutoAccept } from './permissionAutoAccept';
import { useKeybind } from '@/hooks/useKeybind';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
import { extractGitChangedFiles } from './changedFiles';
import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -418,7 +419,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const ensureGitStatus = useGitStore((state) => state.ensureStatus);
const fetchGitStatus = useGitStore((state) => state.fetchStatus);
const clearGitDiffCache = useGitStore((state) => state.clearDiffCache);
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
const [isNarrowComposer, setIsNarrowComposer] = React.useState(false);
const [attachmentPreview, setAttachmentPreview] = React.useState<ToolPopupContent>({
@@ -696,7 +696,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
attachments,
};
}, [resolveInlineFileMention]);
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const prevWasAbortedRef = React.useRef(false);
// Issue linking state
@@ -965,6 +964,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const queuedMessageId = options?.queuedMessageId;
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
const capturedTarget = messageQueueTarget;
// An expired session cannot deliver anything: keep the prompt in the
// composer and point at the login banner instead of burning the send
// on a guaranteed 401.
if (useAuthSessionStore.getState().state !== 'ok') {
toast.error(t('sessionAuth.expired.sendBlocked'));
return;
}
// Snapshot the draft and current-session identity before the first
// async gap so a later sidebar selection cannot reroute the send.
const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null;
@@ -1383,10 +1390,25 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
console.error('Message send failed:', rawMessage || error);
restoreConsumedDrafts();
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) {
setMessage(inputSnapshot.message);
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
// A failed send returns the typed prompt no matter WHY it failed —
// auth, network, server, anything. Losing a long prompt to a toast
// is the one outcome this handler must never produce.
if (inputSnapshot.message) {
if (currentChatDraftIdentityRef.current !== chatDraftIdentity) {
// The user switched sessions mid-send: restore into that
// session's persisted draft, not the visible composer.
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
} else {
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
if (!currentInput || currentInput === inputSnapshot.message) {
setMessage(inputSnapshot.message);
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
} else {
// New typing already lives in the composer; the failed
// prompt joins it instead of clobbering either text.
useInputStore.getState().setPendingInputText(inputSnapshot.message, 'append');
}
}
}
const isSoftNetworkError =
@@ -1697,29 +1719,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
containerRef: dropZoneRef,
});
const startAbortIndicator = React.useCallback(() => {
if (abortTimeoutRef.current) {
clearTimeout(abortTimeoutRef.current);
abortTimeoutRef.current = null;
}
setShowAbortStatus(true);
abortTimeoutRef.current = setTimeout(() => {
setShowAbortStatus(false);
abortTimeoutRef.current = null;
}, 1800);
}, []);
const handleAbort = React.useCallback(() => {
clearAbortPrompt();
startAbortIndicator();
// btw mode: the stop button stops the fork's turn, not the main
// session's.
const abortTarget = isBtwActive && btwSessionId ? btwSessionId : currentSessionId;
void abortCurrentOperation(abortTarget || undefined);
}, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive, startAbortIndicator]);
}, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive]);
const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => {
const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction);
@@ -2568,31 +2576,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
handlePermissionAutoAcceptToggle();
});
// Acknowledging the abort record is what lets the working chip resume for
// the next run; the old "Aborted" banner that used to accompany it is gone.
React.useEffect(() => {
const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
startAbortIndicator();
if (currentSessionId) {
acknowledgeSessionAbort(currentSessionId);
}
const pendingAbort = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
if (!prevWasAbortedRef.current && pendingAbort && currentSessionId) {
acknowledgeSessionAbort(currentSessionId);
}
prevWasAbortedRef.current = pendingAbortBanner;
}, [
abortPromptSessionId,
acknowledgeSessionAbort,
currentSessionId,
showAbortStatus,
startAbortIndicator,
]);
React.useEffect(() => {
return () => {
if (abortTimeoutRef.current) {
clearTimeout(abortTimeoutRef.current);
abortTimeoutRef.current = null;
}
};
}, []);
prevWasAbortedRef.current = pendingAbort;
}, [abortPromptSessionId, acknowledgeSessionAbort, currentSessionId]);
return (
<>
@@ -2663,7 +2655,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
directory={currentSessionDirectoryForSync ?? currentDirectory}
/>
<MemoComposerStatusBar
showAbortStatus={showAbortStatus}
showTodos={composerStatusExtrasEnabled}
leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges
? null
@@ -457,13 +457,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}, [chatRenderMode, isMessageCompleted, isUser, visibleParts]);
const assistantTextParts = React.useMemo(() => {
if (isUser) {
return [];
}
return visibleParts.filter((part) => part.type === 'text');
}, [isUser, visibleParts]);
const toolParts = React.useMemo(() => {
if (isUser) {
return [];
@@ -545,19 +538,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const shouldHideUserMessage = isUser && displayParts.length === 0;
// Message is considered to have an "open step" if info.finish is not yet present
const hasOpenStep = typeof messageFinish !== 'string';
const shouldCoordinateRendering = React.useMemo(() => {
if (isUser) {
return false;
}
if (assistantTextParts.length === 0 || toolParts.length === 0) {
return hasOpenStep;
}
return true;
}, [assistantTextParts.length, toolParts.length, hasOpenStep, isUser]);
const themeVariant = currentTheme?.metadata.variant;
const isDarkTheme = React.useMemo(() => {
if (themeVariant) {
@@ -116,13 +116,11 @@ const TodoItemRow: React.FC<{ todo: TodoItem }> = ({ todo }) => {
const EMPTY_TODOS: TodoItem[] = [];
interface ComposerStatusBarProps {
showAbortStatus?: boolean;
showTodos?: boolean;
leftAccessory?: React.ReactNode;
}
export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
showAbortStatus,
showTodos = true,
leftAccessory,
}) => {
@@ -186,7 +184,7 @@ export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
const hasTodoContent = showTodos && statusSummary.left > 0;
const hasLeftAccessory = Boolean(leftAccessory);
const hasContent = Boolean(showAbortStatus) || hasTodoContent || hasLeftAccessory;
const hasContent = hasTodoContent || hasLeftAccessory;
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
@@ -252,16 +250,7 @@ export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
{/* Left: abort status | pending-changes accessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<Icon name="close-circle" aria-hidden="true" />
{t('chat.statusRow.aborted')}
</span>
</div>
) : leftAccessory ? (
leftAccessory
) : null}
{leftAccessory ?? null}
</div>
{/* Right: todos dropdown */}
@@ -324,7 +324,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentVariant = useConfigStore((state) => state.currentVariant);
const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
const currentVariant = currentVariantSelection.override ?? undefined;
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
@@ -332,6 +334,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const setModel = useConfigStore((state) => state.setModel);
const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant);
const setCurrentVariantOverride = useConfigStore((state) => state.setCurrentVariantOverride);
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
const setAgent = useConfigStore((state) => state.setAgent);
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
@@ -693,6 +696,30 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return variants ? Object.keys(variants) : [];
}, [providers]);
const resolveInheritedVariantForModel = React.useCallback((providerId: string, modelId: string, agentName?: string | null) => {
const variantOptions = getModelVariantOptions(providerId, modelId);
if (variantOptions.length === 0) return undefined;
let currentInherited: string | undefined;
if (currentProviderId === providerId && currentModelId === modelId) {
currentInherited = currentVariantSelection.inherited
?? (currentVariantSelection.override === null || currentVariantSelection.override === undefined
? effectiveCurrentVariant
: undefined);
}
const effectiveAgentName = agentName ?? uiAgentName ?? currentAgentName;
const agent = effectiveAgentName ? agents.find((candidate) => candidate.name === effectiveAgentName) : undefined;
const agentVariant = (
agent?.model?.providerID === providerId
&& agent.model.modelID === modelId
) ? agent.variant : undefined;
const candidates = currentSessionId
? [agentVariant, settingsDefaultVariant, currentInherited]
: [currentInherited, agentVariant, settingsDefaultVariant];
return candidates.find((candidate) => candidate !== undefined && variantOptions.includes(candidate));
}, [agents, currentAgentName, currentModelId, currentProviderId, currentSessionId, currentVariantSelection, effectiveCurrentVariant, getModelVariantOptions, settingsDefaultVariant, uiAgentName]);
const resolveModelVariantSelection = React.useCallback((providerId: string, modelId: string) => {
const variantOptions = getModelVariantOptions(providerId, modelId);
if (variantOptions.length === 0) {
@@ -711,10 +738,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return currentVariant;
}
if (!currentSessionId && settingsDefaultVariant && variantOptions.includes(settingsDefaultVariant)) {
return settingsDefaultVariant;
}
return undefined;
}, [
currentAgentName,
@@ -724,7 +747,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentVariant,
getAgentModelVariantForSession,
getModelVariantOptions,
settingsDefaultVariant,
uiAgentName,
]);
@@ -748,7 +770,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
manualVariantSelectionRef.current = true;
setCurrentVariant(variant);
setCurrentVariantOverride(
variant ?? null,
resolveInheritedVariantForModel(providerId, modelId, agentNameOverride),
);
addRecentEffort(providerId, modelId, variant);
const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName();
@@ -759,9 +784,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
addRecentEffort,
currentSessionId,
getModelVariantOptions,
resolveInheritedVariantForModel,
resolveLiveAgentName,
saveAgentModelVariantForSession,
setCurrentVariant,
setCurrentVariantOverride,
]);
const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => {
@@ -1121,18 +1148,21 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
if (currentVariant && !availableVariants.includes(currentVariant)) {
setCurrentVariant(undefined);
setCurrentVariantOverride(
null,
resolveInheritedVariantForModel(currentProviderId, currentModelId),
);
return;
}
// Draft state (no session yet): seed from settings default, but don't override
// user selection while drafting.
if (!currentSessionId) {
if (!currentVariant && !manualVariantSelectionRef.current) {
if (currentVariantSelection.override === undefined && !manualVariantSelectionRef.current) {
const desired = settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
? settingsDefaultVariant
: undefined;
setCurrentVariant(desired);
setCurrentVariantOverride(desired ?? null, desired);
}
return;
}
@@ -1144,13 +1174,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentModelId,
);
const resolvedSaved = savedVariant && availableVariants.includes(savedVariant)
? savedVariant
: settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
? settingsDefaultVariant
: undefined;
setCurrentVariant(resolvedSaved);
const inheritedVariant = resolveInheritedVariantForModel(currentProviderId, currentModelId);
if (savedVariant && availableVariants.includes(savedVariant)) {
setCurrentVariantOverride(savedVariant, inheritedVariant);
} else if (currentVariantSelection.override === null) {
setCurrentVariantOverride(null, inheritedVariant);
} else {
setCurrentVariant(inheritedVariant);
}
manualVariantSelectionRef.current = false;
}, [
availableVariants,
@@ -1160,8 +1191,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentProviderId,
currentModelId,
currentVariant,
currentVariantSelection.override,
effectiveCurrentVariant,
getAgentModelVariantForSession,
resolveInheritedVariantForModel,
setCurrentVariant,
setCurrentVariantOverride,
settingsDefaultVariant,
]);
+4 -18
View File
@@ -1,11 +1,9 @@
import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { Icon } from "@/components/icon/Icon";
import { useI18n } from "@/lib/i18n";
// The floating assistant-status chip that hovers above the composer while the
// agent works ("Claude is working…", abort notice). ONLY that. The composer's
// agent works ("Claude is working…"). ONLY that. The composer's
// own bar — pending changes, todos dropdown — is ComposerStatusBar: they used
// to share this component, and every restyle of this chip (glass, placement)
// silently dragged the composer bar and its dropdown along with it.
@@ -17,10 +15,8 @@ interface StatusRowProps {
statusText?: string | null;
isGenericStatus?: boolean;
isWaitingForPermission?: boolean;
wasAborted?: boolean;
abortActive?: boolean;
retryInfo?: { attempt?: number; next?: number } | null;
showAbortStatus?: boolean;
agentName?: string;
modelName?: string | null;
providerId?: string | null;
@@ -31,19 +27,16 @@ export const StatusRow: React.FC<StatusRowProps> = ({
statusText = null,
isGenericStatus,
isWaitingForPermission,
wasAborted,
abortActive,
retryInfo,
showAbortStatus,
agentName,
modelName,
providerId,
}) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
const hasContent = isWorking || Boolean(wasAborted) || Boolean(showAbortStatus);
const shouldRenderPlaceholder = !abortActive;
const hasContent = isWorking;
if (!hasContent) {
return null;
@@ -63,14 +56,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
a shrink-to-fit wrapper around it always collapsed to zero. */}
<div className="oc-glass-popover inline-flex w-max max-w-full items-center gap-2 h-8 whitespace-nowrap rounded-full [corner-shape:round] px-3">
<div className="flex items-center min-w-0 gap-2 overflow-x-hidden">
{showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<Icon name="close-circle" aria-hidden="true"/>
{t('chat.statusRow.aborted')}
</span>
</div>
) : shouldRenderPlaceholder ? (
{shouldRenderPlaceholder ? (
<WorkingPlaceholder
key={currentSessionId ?? "no-session"}
isWorking={isWorking}
@@ -2,7 +2,6 @@ import React from 'react';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
import { StatusRow } from './StatusRow';
@@ -12,15 +11,6 @@ import { StatusRow } from './StatusRow';
* labels while still limiting subscriptions to the active assistant message.
*/
export const StatusRowContainer: React.FC = React.memo(() => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const abortRecord = useSessionUIStore(
React.useCallback((state) => {
if (!currentSessionId) {
return null;
}
return state.sessionAbortFlags?.get(currentSessionId) ?? null;
}, [currentSessionId]),
);
const { activeModel, working } = useAssistantStatus();
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const providers = useConfigStore((state) => state.providers);
@@ -35,16 +25,13 @@ export const StatusRowContainer: React.FC = React.memo(() => {
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
}, [activeModel, providers]);
const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged);
return (
<StatusRow
isWorking={working.isWorking}
statusText={working.statusText}
isGenericStatus={working.isGenericStatus}
isWaitingForPermission={working.isWaitingForPermission}
wasAborted={wasAborted || working.wasAborted}
abortActive={wasAborted || working.abortActive}
abortActive={working.abortActive}
retryInfo={working.retryInfo}
agentName={currentAgentName}
modelName={modelDisplayName}
@@ -17,6 +17,15 @@ import React from 'react';
import { isCapacitorApp } from '@/lib/platform';
import type { ComposerEditorHandle } from '../editor/ComposerEditor';
// Android mobile browsers are the pan-mode holdouts this pin exists for on
// the CHAT screen too: interactive-widget=resizes-content is ignored by a
// fair share of Android WebView/Chrome builds, and unlike iOS Safari they do
// not reliably reveal the focused field either — the composer just stays
// behind the keyboard. iOS keeps its browser-native reveal on the chat
// screen, so this stays Android-only there.
// Callers are browser-only React effects, so navigator always exists here.
const isAndroidBrowser = (): boolean => /Android/i.test(navigator.userAgent);
export interface MobileViewportPinOptions {
isMobile: boolean;
/** Composer expanded to fullscreen on mobile. */
@@ -96,12 +105,14 @@ export function useMobileViewportPin(options: MobileViewportPinOptions): void {
};
}, [editorRef, formRef, isFullscreen, isMobile]);
// Draft screen with the keyboard up: anchor the normal-height composer to
// the visible bottom. The chat screen does not need this — its own
// focused-field reveal works there.
// Keyboard up: anchor the normal-height composer to the visible bottom.
// Draft screen on every mobile browser; chat screen only on Android,
// where neither viewport resizing nor the focused-field reveal can be
// relied on (iOS chat keeps the browser's own reveal).
React.useLayoutEffect(() => {
if (!isMobile || isCapacitorApp()) return;
if (!isDraftScreen || isFullscreen || !isFocused) return;
if (isFullscreen || !isFocused) return;
if (!isDraftScreen && !isAndroidBrowser()) return;
const vv = window.visualViewport;
const form = formRef.current;
if (!vv || !form) return;
@@ -1343,16 +1343,6 @@ const AssistantMessageBody = React.memo(({
return resolved ? { id: resolved.id, path: resolved.path } : null;
}, [availableWorktreesByProject, canUseProjectPlanActions, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]);
const hasTools = toolParts.length > 0;
const hasPendingTools = React.useMemo(() => {
return toolParts.some((toolPart) => {
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
const status = state?.status;
return status === 'pending' || status === 'running' || status === 'started';
});
}, [toolParts]);
const isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => {
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
const status = state?.status;
@@ -1381,42 +1371,6 @@ const AssistantMessageBody = React.memo(({
return isActiveTool(toolPart) || isToolFinalized(toolPart);
}, [isActiveTool, isToolFinalized]);
const allToolsFinalized = React.useMemo(() => {
if (toolParts.length === 0) {
return true;
}
if (hasPendingTools) {
return false;
}
return toolParts.every((toolPart) => isToolFinalized(toolPart));
}, [toolParts, hasPendingTools, isToolFinalized]);
const reasoningParts = React.useMemo(() => {
return visibleParts.filter((part) => part.type === 'reasoning');
}, [visibleParts]);
const reasoningComplete = React.useMemo(() => {
if (reasoningParts.length === 0) {
return true;
}
return reasoningParts.every((part) => {
const time = (part as Record<string, unknown>).time as { end?: number } | undefined;
return typeof time?.end === 'number';
});
}, [reasoningParts]);
// Message is considered to have an "open step" if info.finish is not yet present
const hasOpenStep = typeof messageFinish !== 'string';
const shouldHoldForReasoning =
reasoningParts.length > 0 &&
hasTools &&
(hasPendingTools || hasOpenStep || !allToolsFinalized);
const shouldHoldTools = awaitingMessageCompletion
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
const handleForkClick = React.useCallback(
@@ -108,7 +108,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const mouseUpTimeoutRef = React.useRef<number | null>(null);
const isMenuVisibleRef = React.useRef(false);
const activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null);
const createSession = useSessionUIStore((state) => state.createSession);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
@@ -487,18 +486,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
});
}, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]);
const handleCreateNewSession = React.useCallback(async () => {
if (!selectedText) return;
const session = await createSession(undefined, null, null);
if (session) {
setPendingInputText(selectedText, 'replace');
}
hideMenu();
window.getSelection()?.removeAllRanges();
}, [selectedText, createSession, setPendingInputText, hideMenu]);
const currentSession = React.useMemo(() => {
if (!currentSessionId) {
return null;
@@ -700,22 +687,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToInput')}</span>
</button>
<button
onClick={handleCreateNewSession}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.title.newSessionWithSelection')}
type="button"
>
<Icon name="chat-new" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.newSession')}</span>
</button>
{!isVSCodeRuntime() ? (
<button
onClick={handleAddToNotes}
@@ -777,39 +748,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
{t('chat.textSelection.actions.comment')}
</button>
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
<button
onClick={handleAddToChat}
className={cn(
'px-3.5 py-1.5 rounded-full',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.addToCurrentChat')}
type="button"
>
{t('chat.textSelection.actions.addToInput')}
</button>
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
<button
onClick={handleCreateNewSession}
className={cn(
'px-3.5 py-1.5 rounded-full',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.newSessionWithSelection')}
type="button"
>
{t('chat.textSelection.actions.newSession')}
</button>
{!isVSCodeRuntime() ? (
<>
@@ -452,10 +452,13 @@ export const ContextPanel: React.FC = () => {
// Lets an agent's browser.open create the tab it needs when none is open yet.
// Registered from the panel because opening a tab is panel state, not
// something the browser view itself can do before it exists.
// something the browser view itself can do before it exists. Background on
// purpose: an agent working a page must not pop the panel open (or steal
// the active surface) under the user — the tab mounts invisibly, and the
// rail is where the user opens it when curious.
React.useEffect(() => {
if (!effectiveDirectory) return;
return registerBrowserOpener((url) => openContextBrowser(effectiveDirectory, url));
return registerBrowserOpener((url) => openContextBrowser(effectiveDirectory, url, { reveal: false }));
}, [effectiveDirectory, openContextBrowser]);
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
@@ -434,7 +434,6 @@ export const Header: React.FC = () => {
const { t } = useI18n();
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const openContextOverview = useUIStore((state) => state.openContextOverview);
const openContextPlan = useUIStore((state) => state.openContextPlan);
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const sessionTabsEnabled = useUIStore((state) => state.sessionTabsEnabled);
@@ -486,8 +485,6 @@ export const Header: React.FC = () => {
const pathSegments = activeProject.path.split(/[\\/]/).filter(Boolean);
return pathSegments[pathSegments.length - 1] ?? null;
}, [activeProject]);
const quotaResults = useQuotaStore((state) => state.results);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
const { isMobile } = useDeviceInfo();
@@ -1265,21 +1262,6 @@ export const Header: React.FC = () => {
const isContextPanelActive = activeContextMode === 'context';
const handleOpenContextPlan = React.useCallback(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
return;
}
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
if (getActiveContextMode(panelState) === 'plan') {
closeContextPanel(directory);
return;
}
openContextPlan(directory);
}, [closeContextPanel, openContextPlan, openDirectory]);
const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS;
// Left padding the header needs to clear the OS window controls (macOS
@@ -1450,16 +1432,6 @@ export const Header: React.FC = () => {
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
}, [shortcutOverrides]);
// Desktop keeps instances only: quota and MCP now live in the work-status
// panel, which reports them per session rather than per window. The mobile
// menu below is untouched — it has no panel to defer to.
const servicesTabs = React.useMemo(() => {
const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: React.ReactNode }> = [];
if (isDesktopApp) {
base.push({ value: 'instance', label: t('layout.services.instance'), icon: <Icon name="server" className="h-3.5 w-3.5" /> });
}
return base;
}, [isDesktopApp, t]);
useKeybinds({
@@ -42,6 +42,7 @@ export const DefaultsSettings: React.FC = () => {
const setModel = useConfigStore((state) => state.setModel);
const setAgent = useConfigStore((state) => state.setAgent);
const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant);
const setCurrentVariantOverride = useConfigStore((state) => state.setCurrentVariantOverride);
const setSettingsDefaultModel = useConfigStore((state) => state.setSettingsDefaultModel);
const setSettingsDefaultVariant = useConfigStore((state) => state.setSettingsDefaultVariant);
const setSettingsDefaultAgent = useConfigStore((state) => state.setSettingsDefaultAgent);
@@ -210,7 +211,7 @@ export const DefaultsSettings: React.FC = () => {
setDefaultVariant(newValue);
setSettingsDefaultVariant(newValue);
if (!chatHasOwnModel) {
setCurrentVariant(newValue);
setCurrentVariantOverride(newValue ?? null, newValue);
}
try {
@@ -219,7 +220,7 @@ export const DefaultsSettings: React.FC = () => {
console.warn('Failed to save default variant:', error);
}
},
[chatHasOwnModel, setCurrentVariant, setSettingsDefaultVariant]
[chatHasOwnModel, setCurrentVariantOverride, setSettingsDefaultVariant]
);
const handleAgentChange = React.useCallback(
@@ -4,7 +4,7 @@ import type { Event } from '@opencode-ai/sdk/v2/client';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { deriveRecentSessions } from '../recent/activitySections';
import { applyGlobalSessionStatusEvent, useGlobalSessionStatusStore , replaceGlobalSessionStatusById} from '@/sync/global-session-status';
import { applyGlobalSessionStatusEvent, replaceGlobalSessionStatusById } from '@/sync/global-session-status';
import {
buildSidebarSessionProjection,
getDescendantIds,
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import type { Session } from '@opencode-ai/sdk/v2';
import { useGlobalSessionStatusStore , replaceGlobalSessionStatusById} from '@/sync/global-session-status';
import { replaceGlobalSessionStatusById } from '@/sync/global-session-status';
import { useNotificationStore } from '@/sync/notification-store';
import { useCollapsedSessionActivityState } from './collapsedActivityState';
import type { SessionNode } from '../types';
@@ -7,11 +7,10 @@ import React, {
} from 'react';
import { flushSync } from 'react-dom';
import type { Theme, ThemeMode } from '@/types/theme';
import type { DesktopSettings } from '@/lib/desktop';
import { isDesktopLocalOriginActive, isDesktopShell as detectDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { setDesktopWindowTheme } from '@/lib/desktopNative';
import { CSSVariableGenerator } from '@/lib/theme/cssGenerator';
import { updateDesktopSettings } from '@/lib/persistence';
import { type SettingsSyncedDetail, updateDesktopSettings } from '@/lib/persistence';
import {
themes,
getThemeById,
@@ -622,7 +621,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return;
}
const handleSettingsSynced = (event: Event) => {
const detail = (event as CustomEvent<DesktopSettings>).detail;
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail?.settings;
if (!detail) {
return;
}
@@ -273,16 +273,16 @@ export const useKeyboardShortcuts = () => {
if (state.isSettingsDialogOpen || hasOverlay) return false;
const config = useConfigStore.getState();
if (config.getCurrentModelVariants().length === 0) return false;
config.cycleCurrentVariant();
const nextVariantOverride = config.cycleCurrentVariant();
const sessionId = useSessionUIStore.getState().currentSessionId;
const { currentVariant, currentAgentName, currentProviderId, currentModelId } = useConfigStore.getState();
const { currentAgentName, currentProviderId, currentModelId } = useConfigStore.getState();
if (sessionId && currentAgentName && currentProviderId && currentModelId) {
useSelectionStore.getState().saveAgentModelVariantForSession(
sessionId,
currentAgentName,
currentProviderId,
currentModelId,
currentVariant,
nextVariantOverride,
);
}
},
@@ -71,10 +71,9 @@ export const useMiniChatKeyboardShortcuts = () => {
const configState = useConfigStore.getState();
if (configState.getCurrentModelVariants().length === 0) return false;
configState.cycleCurrentVariant();
const nextVariantOverride = configState.cycleCurrentVariant();
const sessionId = useSessionUIStore.getState().currentSessionId;
const {
currentVariant,
currentAgentName,
currentProviderId,
currentModelId,
@@ -85,7 +84,7 @@ export const useMiniChatKeyboardShortcuts = () => {
currentAgentName,
currentProviderId,
currentModelId,
currentVariant,
nextVariantOverride,
);
}
},
+5 -4
View File
@@ -1903,7 +1903,6 @@ export const dict = {
'chat.statusRow.actions.stopGeneratingAria': 'Generierung stoppen',
'chat.statusRow.tasksTitle': 'Aufgaben',
'chat.statusRow.summary.activeLeft': '{active} aktiv · {left} übrig',
'chat.statusRow.aborted': 'Abgebrochen',
'chat.revertIndicator.redo': 'Wiederholen',
'chat.revertIndicator.redoAria': 'Wiederholen — wiederhergestellte Nachrichten',
'chat.revertPopover.title': 'Zurückgesetzt',
@@ -2014,10 +2013,8 @@ export const dict = {
'chat.textSelection.title.commentOnSelection': 'Auswahl kommentieren',
'chat.textSelection.comment.placeholder': 'Optionalen Kommentar hinzufügen...',
'chat.textSelection.comment.attach': 'Anhängen',
'chat.textSelection.actions.newSession': 'Neue Sitzung',
'chat.textSelection.actions.addToNotes': 'Zu Notizen hinzufügen',
'chat.textSelection.title.addToCurrentChat': 'Zum aktuellen Chat hinzufügen',
'chat.textSelection.title.newSessionWithSelection': 'Neue Sitzung mit Auswahl erstellen',
'chat.textSelection.title.saveInsightToNotes': 'Ausgewählten Text zu Notizen speichern',
'chat.messageBody.actions.revertAria': 'Zu dieser Nachricht zurückkehren',
'chat.messageBody.actions.revert': 'Von hier zurückkehren',
@@ -2511,6 +2508,9 @@ export const dict = {
'sessionAuth.error.passkeySignInCanceled': 'Passkey-Anmeldung wurde abgebrochen.',
'sessionAuth.error.enterPasswordForPasskey': 'Geben Sie Ihr Passwort ein, um einen Passkey hinzuzufügen.',
'sessionAuth.locked.tunnelTitle': 'Tunnel-Zugriff erforderlich',
'sessionAuth.expired.banner': 'Deine Sitzung ist abgelaufen — melde dich an, um fortzufahren.',
'sessionAuth.expired.loginAction': 'Anmelden',
'sessionAuth.expired.sendBlocked': 'Sitzung abgelaufen — melde dich an, um Nachrichten zu senden.',
'sessionAuth.locked.unlockTitle': 'OpenChamber entsperren',
'sessionAuth.locked.tunnelDescription': 'Öffnen Sie diesen Tunnel über den Einmal-Verbindungslink aus der Desktop-Anwendung.',
'sessionAuth.locked.passwordDescription': 'Diese Sitzung ist passwortgeschützt.',
@@ -3091,7 +3091,8 @@ export const dict = {
'chat.commandAutocomplete.command.scheduleTaskDescription': 'Eine geplante Aufgabe erstellen',
'chat.chatInput.toast.scheduleTaskFailed': 'Aufgabe konnte nicht geplant werden',
'chat.container.sessionLoadError.title': 'Sitzung konnte nicht geladen werden',
'chat.container.sessionLoadError.description': 'Die Sitzung konnte nicht geladen werden.',
'chat.container.sessionLoadError.description': 'Die Unterhaltung konnte nicht geladen werden — der Server ist womöglich offline oder nicht erreichbar. Nichts ist verloren; versuche es erneut, sobald er wieder da ist.',
'chat.container.sessionLoadError.authDescription': 'Deine Sitzung ist abgelaufen, daher hat der Server die Anfrage abgelehnt. Melde dich an, dann wird die Unterhaltung geladen.',
'chat.container.sessionLoadError.retry': 'Erneut versuchen',
'sessions.sidebar.group.empty.loadingSessions': 'Sitzungen werden geladen...',
'sessions.sidebar.group.empty.loadFailed': 'Sitzungen konnten nicht geladen werden',
+5 -4
View File
@@ -2084,7 +2084,6 @@ export const dict = {
'chat.statusRow.tasksTitle': 'Tasks',
'chat.statusRow.modelStatus': '{model} is {status}',
'chat.statusRow.summary.activeLeft': '{active} active · {left} left',
'chat.statusRow.aborted': 'Aborted',
'chat.revertIndicator.redo': 'Redo',
'chat.revertIndicator.redoAria': 'Redo — restore reverted messages',
'chat.revertPopover.title': 'Reverted',
@@ -2162,7 +2161,8 @@ export const dict = {
'chat.btw.promoteAria': 'Keep as a separate session',
'chat.btw.toast.promoteFailed': 'Failed to keep the btw session',
'chat.container.sessionLoadError.title': 'Session could not be loaded',
'chat.container.sessionLoadError.description': 'Check the connection and try loading this session again.',
'chat.container.sessionLoadError.description': 'The conversation could not be fetched — the server may be offline or unreachable. Nothing is lost; retry once it is back.',
'chat.container.sessionLoadError.authDescription': 'Your session expired, so the server refused the request. Log in and the conversation will load.',
'chat.container.sessionLoadError.retry': 'Try again',
'sessions.sidebar.group.empty.loadingSessions': 'Loading sessions…',
'sessions.sidebar.group.empty.loadFailed': 'Could not refresh sessions.',
@@ -2205,10 +2205,8 @@ export const dict = {
'chat.textSelection.title.commentOnSelection': 'Comment on selection',
'chat.textSelection.comment.placeholder': 'Add an optional comment...',
'chat.textSelection.comment.attach': 'Attach',
'chat.textSelection.actions.newSession': 'New session',
'chat.textSelection.actions.addToNotes': 'Add to notes',
'chat.textSelection.title.addToCurrentChat': 'Add to current chat',
'chat.textSelection.title.newSessionWithSelection': 'Create new session with selection',
'chat.textSelection.title.saveInsightToNotes': 'Save selected text to notes',
'chat.messageBody.actions.revertAria': 'Revert to this message',
'chat.messageBody.actions.revert': 'Revert from here',
@@ -2707,6 +2705,9 @@ export const dict = {
'sessionAuth.error.passkeySignInCanceled': 'Passkey sign-in was canceled.',
'sessionAuth.error.enterPasswordForPasskey': 'Enter your password to add a passkey.',
'sessionAuth.locked.tunnelTitle': 'Tunnel access required',
'sessionAuth.expired.banner': 'Your session expired — log in to continue.',
'sessionAuth.expired.loginAction': 'Log in',
'sessionAuth.expired.sendBlocked': 'Session expired — log in to send messages.',
'sessionAuth.locked.unlockTitle': 'Unlock OpenChamber',
'sessionAuth.locked.tunnelDescription': 'Open this tunnel using the one-time connect link from the desktop app.',
'sessionAuth.locked.passwordDescription': 'This session is password-protected.',
+5 -4
View File
@@ -2062,7 +2062,6 @@ export const dict: Record<I18nKey, string> = {
"chat.statusRow.tasksTitle": "Tareas",
"chat.statusRow.modelStatus": "{model} · {status}",
"chat.statusRow.summary.activeLeft": "{active} activas · {left} restantes",
"chat.statusRow.aborted": "Interrumpido",
"chat.revertIndicator.redo": "Rehacer",
"chat.revertIndicator.redoAria": "Rehacer — restaurar mensajes revertidos",
"chat.revertPopover.title": "Revertidos",
@@ -2140,7 +2139,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': 'No se pudo conservar la sesión btw',
"chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.",
"chat.container.sessionLoadError.title": "No se pudo cargar la sesión",
"chat.container.sessionLoadError.description": "Comprueba la conexión e intenta cargar esta sesión de nuevo.",
"chat.container.sessionLoadError.description": "No se pudo obtener la conversación: puede que el servidor esté apagado o inaccesible. No se perdió nada; reintenta cuando vuelva.",
"chat.container.sessionLoadError.authDescription": "Tu sesión expiró, por lo que el servidor rechazó la solicitud. Inicia sesión y la conversación se cargará.",
"chat.container.sessionLoadError.retry": "Reintentar",
"sessions.sidebar.group.empty.loadingSessions": "Cargando sesiones…",
"sessions.sidebar.group.empty.loadFailed": "No se pudieron actualizar las sesiones.",
@@ -2183,10 +2183,8 @@ export const dict: Record<I18nKey, string> = {
"chat.textSelection.title.commentOnSelection": "Comentar la selección",
"chat.textSelection.comment.placeholder": "Añade un comentario opcional...",
"chat.textSelection.comment.attach": "Adjuntar",
"chat.textSelection.actions.newSession": "Nueva sesión",
"chat.textSelection.actions.addToNotes": "Añadir a las notas",
"chat.textSelection.title.addToCurrentChat": "Añadir al chat actual",
"chat.textSelection.title.newSessionWithSelection": "Crear nueva sesión con selección",
"chat.textSelection.title.saveInsightToNotes": "Guardar texto seleccionado en notas",
"chat.messageBody.actions.revertAria": "Volver a este mensaje",
"chat.messageBody.actions.revert": "Volver desde aquí",
@@ -2673,6 +2671,9 @@ export const dict: Record<I18nKey, string> = {
"sessionAuth.error.passkeySignInCanceled": "El inicio de sesión con clave de paso se canceló.",
"sessionAuth.error.enterPasswordForPasskey": "Introduce tu contraseña para añadir una clave de paso.",
"sessionAuth.locked.tunnelTitle": "Se requiere acceso por túnel",
"sessionAuth.expired.banner": "Tu sesión expiró: inicia sesión para continuar.",
"sessionAuth.expired.loginAction": "Iniciar sesión",
"sessionAuth.expired.sendBlocked": "Sesión expirada: inicia sesión para enviar mensajes.",
"sessionAuth.locked.unlockTitle": "Desbloquear OpenChamber",
"sessionAuth.locked.tunnelDescription": "Abre este túnel usando el enlace de conexión única desde la aplicación de escritorio.",
"sessionAuth.locked.passwordDescription": "Esta sesión está protegida con contraseña.",
+5 -4
View File
@@ -1826,7 +1826,6 @@ export const dict = {
'chat.statusRow.tasksTitle': 'Tâches',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active} actif · {left} gauche',
'chat.statusRow.aborted': 'Avorté',
'chat.revertIndicator.redo': 'Refaire',
'chat.revertIndicator.redoAria': 'Rétablir : restaurer les messages annulés',
'chat.revertPopover.title': 'Rétabli',
@@ -1893,7 +1892,8 @@ export const dict = {
'chat.btw.toast.promoteFailed': 'Échec de la conservation de la session btw',
'chat.container.readOnlySubagentPromptBanner': 'Les sessions de sous-agent ne peuvent pas être invitées.',
'chat.container.sessionLoadError.title': 'Impossible de charger la session',
'chat.container.sessionLoadError.description': 'Vérifiez la connexion et essayez de charger à nouveau cette session.',
'chat.container.sessionLoadError.description': 'Impossible de récupérer la conversation — le serveur est peut-être hors ligne ou injoignable. Rien n\'est perdu ; réessayez quand il sera de retour.',
'chat.container.sessionLoadError.authDescription': 'Votre session a expiré, le serveur a donc refusé la requête. Connectez-vous et la conversation se chargera.',
'chat.container.sessionLoadError.retry': 'Réessayer',
'sessions.sidebar.group.empty.loadingSessions': 'Chargement des sessions…',
'sessions.sidebar.group.empty.loadFailed': 'Impossible dactualiser les sessions.',
@@ -1932,10 +1932,8 @@ export const dict = {
'chat.textSelection.title.commentOnSelection': 'Commenter la sélection',
'chat.textSelection.comment.placeholder': 'Ajouter un commentaire facultatif...',
'chat.textSelection.comment.attach': 'Joindre',
'chat.textSelection.actions.newSession': 'Nouvelle session',
'chat.textSelection.actions.addToNotes': 'Ajouter aux notes',
'chat.textSelection.title.addToCurrentChat': 'Ajouter au chat actuel',
'chat.textSelection.title.newSessionWithSelection': 'Créer une nouvelle session avec sélection',
'chat.textSelection.title.saveInsightToNotes': 'Enregistrer le texte sélectionné dans les notes',
'chat.messageBody.actions.revertAria': 'Revenir à ce message',
'chat.messageBody.actions.revert': 'Revenir à partir d\'ici',
@@ -2411,6 +2409,9 @@ export const dict = {
'sessionAuth.error.passkeySignInCanceled': 'La connexion par mot de passe a été annulée.',
'sessionAuth.error.enterPasswordForPasskey': 'Entrez votre mot de passe pour ajouter un mot de passe.',
'sessionAuth.locked.tunnelTitle': 'Accès au tunnel requis',
'sessionAuth.expired.banner': 'Votre session a expiré — connectez-vous pour continuer.',
'sessionAuth.expired.loginAction': 'Se connecter',
'sessionAuth.expired.sendBlocked': 'Session expirée — connectez-vous pour envoyer des messages.',
'sessionAuth.locked.unlockTitle': 'Débloquez OpenChamber',
'sessionAuth.locked.tunnelDescription': 'Ouvrez ce tunnel à l\'aide du lien de connexion unique depuis l\'application de bureau.',
'sessionAuth.locked.passwordDescription': 'Cette session est protégée par mot de passe.',
+5 -4
View File
@@ -2080,7 +2080,6 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.tasksTitle': 'タスク',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active}アクティブ · {left}残り',
'chat.statusRow.aborted': '中止されました',
'chat.revertIndicator.redo': 'やり直し',
'chat.revertIndicator.redoAria': 'やり直し — 元に戻したメッセージを復元',
'chat.revertPopover.title': '元に戻しました',
@@ -2158,7 +2157,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': 'btwセッションを保持できませんでした',
'chat.container.readOnlySubagentPromptBanner': 'サブエージェントセッションはプロンプトを受け付けません。',
'chat.container.sessionLoadError.title': 'セッションを読み込めませんでした',
'chat.container.sessionLoadError.description': '接続を確認して、このセッションをもう一度読み込んでください。',
'chat.container.sessionLoadError.description': '会話を取得できませんでした。サーバーが停止中か到達できない可能性があります。データは失われていません。復旧後に再試行してください。',
'chat.container.sessionLoadError.authDescription': 'セッションの有効期限が切れたため、サーバーがリクエストを拒否しました。ログインすると会話が読み込まれます。',
'chat.container.sessionLoadError.retry': '再試行',
'sessions.sidebar.group.empty.loadingSessions': 'セッションを読み込んでいます…',
'sessions.sidebar.group.empty.loadFailed': 'セッションを更新できませんでした。',
@@ -2201,10 +2201,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.title.commentOnSelection': '選択範囲にコメント',
'chat.textSelection.comment.placeholder': '任意のコメントを追加...',
'chat.textSelection.comment.attach': '添付',
'chat.textSelection.actions.newSession': '新しいセッション',
'chat.textSelection.actions.addToNotes': 'メモに追加',
'chat.textSelection.title.addToCurrentChat': '現在のチャットに追加',
'chat.textSelection.title.newSessionWithSelection': '選択範囲で新しいセッションを作成',
'chat.textSelection.title.saveInsightToNotes': '選択テキストをメモに保存',
'chat.messageBody.actions.revertAria': 'このメッセージに戻す',
'chat.messageBody.actions.revert': 'ここから元に戻す',
@@ -2706,6 +2704,9 @@ export const dict: Record<I18nKey, string> = {
'sessionAuth.error.passkeySignInCanceled': 'パスキーサインインがキャンセルされました。',
'sessionAuth.error.enterPasswordForPasskey': 'パスキーを追加するためにパスワードを入力してください。',
'sessionAuth.locked.tunnelTitle': 'トンネルアクセスが必要',
'sessionAuth.expired.banner': 'セッションの有効期限が切れました。続行するにはログインしてください。',
'sessionAuth.expired.loginAction': 'ログイン',
'sessionAuth.expired.sendBlocked': 'セッションが切れています。メッセージを送るにはログインしてください。',
'sessionAuth.locked.unlockTitle': 'OpenChamberのロックを解除',
'sessionAuth.locked.tunnelDescription': 'デスクトップアプリのワンタイム接続リンクを使用してこのトンネルを開きます。',
'sessionAuth.locked.passwordDescription': 'このセッションはパスワードで保護されています。',
+5 -4
View File
@@ -2086,7 +2086,6 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.tasksTitle': '작업',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active}개 활성 · {left}개 남음',
'chat.statusRow.aborted': '중단됨',
'chat.revertIndicator.redo': '다시 실행',
'chat.revertIndicator.redoAria': '다시 실행 — 되돌린 메시지 복원',
'chat.revertPopover.title': '되돌림',
@@ -2164,7 +2163,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': 'btw 세션을 유지하지 못했습니다',
'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.',
'chat.container.sessionLoadError.title': '세션을 불러올 수 없습니다',
'chat.container.sessionLoadError.description': '연결을 확인한 후 이 세션을 다시 불러오세요.',
'chat.container.sessionLoadError.description': '대화를 가져오지 못했습니다. 서버가 꺼져 있거나 연결할 수 없는 상태일 수 있습니다. 데이터는 사라지지 않았으니 복구되면 다시 시도하세요.',
'chat.container.sessionLoadError.authDescription': '세션이 만료되어 서버가 요청을 거부했습니다. 로그인하면 대화가 로드됩니다.',
'chat.container.sessionLoadError.retry': '다시 시도',
'sessions.sidebar.group.empty.loadingSessions': '세션을 불러오는 중…',
'sessions.sidebar.group.empty.loadFailed': '세션을 새로 고칠 수 없습니다.',
@@ -2207,10 +2207,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.title.commentOnSelection': '선택 영역에 댓글 달기',
'chat.textSelection.comment.placeholder': '선택적 댓글 추가...',
'chat.textSelection.comment.attach': '첨부',
'chat.textSelection.actions.newSession': '새 세션',
'chat.textSelection.actions.addToNotes': '메모에 추가',
'chat.textSelection.title.addToCurrentChat': '현재 채팅에 추가',
'chat.textSelection.title.newSessionWithSelection': '선택한 내용으로 새 세션 생성',
'chat.textSelection.title.saveInsightToNotes': '선택한 텍스트를 메모에 저장',
'chat.messageBody.actions.revertAria': '이 메시지로 되돌리기',
'chat.messageBody.actions.revert': '여기부터 되돌리기',
@@ -2707,6 +2705,9 @@ export const dict: Record<I18nKey, string> = {
'sessionAuth.error.passkeySignInCanceled': '패스키 로그인이 취소되었습니다.',
'sessionAuth.error.enterPasswordForPasskey': '패스키를 추가하려면 비밀번호를 입력하세요.',
'sessionAuth.locked.tunnelTitle': '터널 접근 필요',
'sessionAuth.expired.banner': '세션이 만료되었습니다. 계속하려면 로그인하세요.',
'sessionAuth.expired.loginAction': '로그인',
'sessionAuth.expired.sendBlocked': '세션이 만료되었습니다. 메시지를 보내려면 로그인하세요.',
'sessionAuth.locked.unlockTitle': 'OpenChamber 잠금 해제',
'sessionAuth.locked.tunnelDescription': '데스크톱 앱의 일회용 연결 링크로 이 터널을 여세요.',
'sessionAuth.locked.passwordDescription': '이 세션은 비밀번호로 보호됩니다.',
+5 -4
View File
@@ -776,7 +776,6 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.tasksTitle': 'Zadania',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active} aktywne · {left} pozostało',
'chat.statusRow.aborted': 'Przerwane',
'chat.revertIndicator.redo': 'Ponów',
'chat.revertIndicator.redoAria': 'Ponów — przywróć cofnięte wiadomości',
'chat.revertPopover.title': 'Cofnięte',
@@ -853,7 +852,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': 'Nie udało się zachować sesji btw',
'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.',
'chat.container.sessionLoadError.title': 'Nie udało się wczytać sesji',
'chat.container.sessionLoadError.description': 'Sprawdź połączenie i spróbuj ponownie wczytać tę sesję.',
'chat.container.sessionLoadError.description': 'Nie udało się pobrać rozmowy — serwer może być wyłączony lub nieosiągalny. Nic nie przepadło; spróbuj ponownie, gdy wróci.',
'chat.container.sessionLoadError.authDescription': 'Sesja wygasła, więc serwer odrzucił żądanie. Zaloguj się, a rozmowa się wczyta.',
'chat.container.sessionLoadError.retry': 'Spróbuj ponownie',
'sessions.sidebar.group.empty.loadingSessions': 'Wczytywanie sesji…',
'sessions.sidebar.group.empty.loadFailed': 'Nie udało się odświeżyć sesji.',
@@ -896,10 +896,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.title.commentOnSelection': 'Skomentuj zaznaczenie',
'chat.textSelection.comment.placeholder': 'Dodaj opcjonalny komentarz...',
'chat.textSelection.comment.attach': 'Załącz',
'chat.textSelection.actions.newSession': 'Nowa sesja',
'chat.textSelection.actions.addToNotes': 'Dodaj do notatek',
'chat.textSelection.title.addToCurrentChat': 'Dodaj do obecnego czatu',
'chat.textSelection.title.newSessionWithSelection': 'Utwórz nową sesję z zaznaczeniem',
'chat.textSelection.title.saveInsightToNotes': 'Zapisz zaznaczony tekst do notatek',
'chat.messageBody.actions.revertAria': 'Cofnij do tej wiadomości',
'chat.messageBody.actions.revert': 'Cofnij od tego miejsca',
@@ -2865,6 +2863,9 @@ export const dict: Record<I18nKey, string> = {
'sessionAuth.locked.passwordDescription': 'Ta sesja jest chroniona hasłem.',
'sessionAuth.locked.tunnelDescription': 'Otwórz ten tunel za pomocą jednorazowego linku połączenia z aplikacji desktopowej.',
'sessionAuth.locked.tunnelTitle': 'Wymagany dostęp przez tunel',
'sessionAuth.expired.banner': 'Sesja wygasła — zaloguj się, aby kontynuować.',
'sessionAuth.expired.loginAction': 'Zaloguj się',
'sessionAuth.expired.sendBlocked': 'Sesja wygasła — zaloguj się, aby wysyłać wiadomości.',
'sessionAuth.locked.unlockTitle': 'Odblokuj OpenChamber',
'sessionAuth.password.placeholder': 'Wpisz hasło',
'sessionAuth.toast.passkeyAdded': 'Dodano klucz dostępu',
+5 -4
View File
@@ -2062,7 +2062,6 @@ export const dict: Record<I18nKey, string> = {
"chat.statusRow.tasksTitle": "Tarefas",
"chat.statusRow.modelStatus": "{model} · {status}",
"chat.statusRow.summary.activeLeft": "{active} ativas · {left} restantes",
"chat.statusRow.aborted": "Interrompido",
"chat.revertIndicator.redo": "Refazer",
"chat.revertIndicator.redoAria": "Refazer — restaurar mensagens revertidas",
"chat.revertPopover.title": "Revertidas",
@@ -2140,7 +2139,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': 'Falha ao manter a sessão btw',
"chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.",
"chat.container.sessionLoadError.title": "Não foi possível carregar a sessão",
"chat.container.sessionLoadError.description": "Verifique a conexão e tente carregar esta sessão novamente.",
"chat.container.sessionLoadError.description": "Não foi possível buscar a conversa — o servidor pode estar desligado ou inacessível. Nada foi perdido; tente novamente quando ele voltar.",
"chat.container.sessionLoadError.authDescription": "Sua sessão expirou, então o servidor recusou a solicitação. Entre e a conversa será carregada.",
"chat.container.sessionLoadError.retry": "Tentar novamente",
"sessions.sidebar.group.empty.loadingSessions": "Carregando sessões…",
"sessions.sidebar.group.empty.loadFailed": "Não foi possível atualizar as sessões.",
@@ -2183,10 +2183,8 @@ export const dict: Record<I18nKey, string> = {
"chat.textSelection.title.commentOnSelection": "Comentar a seleção",
"chat.textSelection.comment.placeholder": "Adicione um comentário opcional...",
"chat.textSelection.comment.attach": "Anexar",
"chat.textSelection.actions.newSession": "Nova sessão",
"chat.textSelection.actions.addToNotes": "Adicionar às notas",
"chat.textSelection.title.addToCurrentChat": "Adicionar ao chat atual",
"chat.textSelection.title.newSessionWithSelection": "Criar nova sessão com seleção",
"chat.textSelection.title.saveInsightToNotes": "Salvar texto selecionado em notas",
"chat.messageBody.actions.revertAria": "Voltar para esta mensagem",
"chat.messageBody.actions.revert": "Voltar daqui",
@@ -2673,6 +2671,9 @@ export const dict: Record<I18nKey, string> = {
"sessionAuth.error.passkeySignInCanceled": "O início de sessão com chave de acesso foi cancelado.",
"sessionAuth.error.enterPasswordForPasskey": "Digite sua senha para adicionar uma chave de acesso.",
"sessionAuth.locked.tunnelTitle": "É necessário acesso por túnel",
"sessionAuth.expired.banner": "Sua sessão expirou — entre para continuar.",
"sessionAuth.expired.loginAction": "Entrar",
"sessionAuth.expired.sendBlocked": "Sessão expirada — entre para enviar mensagens.",
"sessionAuth.locked.unlockTitle": "Desbloquear OpenChamber",
"sessionAuth.locked.tunnelDescription": "Abra este túnel usando o link de conexão única do aplicativo desktop.",
"sessionAuth.locked.passwordDescription": "Esta sessão está protegida com senha.",
@@ -206,9 +206,9 @@ export const settingsDict = {
"settings.openchamber.tunnel.toast.addManagedRemoteTokenBeforeStarting": "Перед початком додайте токен керованого віддаленого тунелю",
"settings.openchamber.tunnel.toast.startFailed": "Не вдалося запустити тунель",
"settings.openchamber.tunnel.toast.startedButNoPublicUrl": "Тунель запущено, але публічний URL не повернувся",
"settings.openchamber.tunnel.toast.replacedTunnelSingleSingle": "Попередній тунель замінено: відкликано 1 посилання, анульовано 1 сесія.",
"settings.openchamber.tunnel.toast.replacedTunnelSingleSingle": "Попередній тунель замінено: відкликано 1 посилання, анульовано 1 сесію.",
"settings.openchamber.tunnel.toast.replacedTunnelSingleManySessions": "Попередній тунель замінено: відкликано 1 посилання, анульовано сесій: {invalidatedSessionCount}.",
"settings.openchamber.tunnel.toast.replacedTunnelManyLinksSingleSession": "Попередній тунель замінено: відкликано посилань: {revokedBootstrapCount}, анульовано 1 сесія.",
"settings.openchamber.tunnel.toast.replacedTunnelManyLinksSingleSession": "Попередній тунель замінено: відкликано посилань: {revokedBootstrapCount}, анульовано 1 сесію.",
"settings.openchamber.tunnel.toast.replacedTunnelManyMany": "Попередній тунель замінено: відкликано посилань: {revokedBootstrapCount}, анульовано сесій: {invalidatedSessionCount}.",
"settings.openchamber.tunnel.toast.linkReady": "Тунель готовий",
"settings.openchamber.tunnel.toast.stopped": "Тунель зупинено",
@@ -2145,7 +2145,7 @@ export const settingsDict = {
"settings.magicPrompts.page.group.planImprove.title": "Поліпшити план",
"settings.magicPrompts.page.group.planImprove.description": "Прихований промпт, який використовується під час надсилання збереженого плану в потік покращення.",
"settings.magicPrompts.page.group.planTodo.title": "Планування Todo",
"settings.magicPrompts.page.group.planTodo.description": "Прихований промпт, який використовується під час надсилання завдання до нового сесії планування.",
"settings.magicPrompts.page.group.planTodo.description": "Прихований промпт, який використовується під час надсилання завдання до нової сесії планування.",
"settings.magicPrompts.page.group.planImplement.title": "Реалізувати план",
"settings.magicPrompts.page.group.planImplement.description": "Прихований промпт, який використовується під час надсилання збереженого плану в потік реалізації.",
"settings.magicPrompts.page.group.sessionSummary.title": "Підсумок сесії",
+14 -13
View File
@@ -504,12 +504,12 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.bulkActions.failedDeletePlural": "Не вдалося видалити сесії {count}",
"sessions.sidebar.bulkActions.archivedSingle": "Заархівовано сесію: {count}",
"sessions.sidebar.bulkActions.archivedPlural": "Заархівовано сесій: {count}",
"sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесія {count}",
"sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесію {count}",
"sessions.sidebar.bulkActions.failedArchivePlural": "Не вдалося архівувати сесії {count}",
"sessions.sidebar.bulkActions.restore": "Відновити",
"sessions.sidebar.bulkActions.restoredSingle": "Відновлено сесію: {count}",
"sessions.sidebar.bulkActions.restoredPlural": "Відновлено сесій: {count}",
"sessions.sidebar.bulkActions.failedRestoreSingle": "Не вдалося відновити сесія {count}",
"sessions.sidebar.bulkActions.failedRestoreSingle": "Не вдалося відновити сесію {count}",
"sessions.sidebar.bulkActions.failedRestorePlural": "Не вдалося відновити сесії {count}",
"sessions.sidebar.folders.none": "Папок ще немає",
"sessions.sidebar.folders.newFolderEllipsis": "Нова папка...",
@@ -560,9 +560,9 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.export.dialog.descriptionMany": "Ця сесія має {count} завдань під-агентів. Додати їх до експорту?",
"sessions.sidebar.session.export.dialog.includeSubtasks": "Додати завдання під-агентів",
"sessions.sidebar.session.export.dialog.confirm": "Експортувати",
"sessions.sidebar.session.status.active": "Сесія активний",
"sessions.sidebar.session.status.active": "Сесія активна",
"sessions.sidebar.session.status.unread": "Непрочитані оновлення",
"sessions.sidebar.session.status.pinned": "Закріплений сесія",
"sessions.sidebar.session.status.pinned": "Закріплена сесія",
"sessions.sidebar.session.status.movingToWorktree": "Перенесення сесії в новий worktree",
"sessions.sidebar.session.status.permissionRequired": "Потрібен дозвіл",
"sessions.sidebar.session.status.questionPendingSingle": "1 запитання очікує відповіді",
@@ -571,8 +571,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.status.lastTurnDuration": "Останній хід тривав {duration}",
"sessions.sidebar.session.subsessions.collapse": "Згорнути підсесії",
"sessions.sidebar.session.subsessions.expand": "Розгорнути підсесії",
"sessions.sidebar.dialogs.deleteSession.title": "Видалити сесія?",
"sessions.sidebar.dialogs.archiveSession.title": "Архівувати сесія?",
"sessions.sidebar.dialogs.deleteSession.title": "Видалити сесію?",
"sessions.sidebar.dialogs.archiveSession.title": "Архівувати сесію?",
"sessions.sidebar.dialogs.deleteSession.withOneSubtask": "\"{sessionTitle}\" і його підзавдання {count} буде остаточно видалено.",
"sessions.sidebar.dialogs.deleteSession.withManySubtasks": "\"{sessionTitle}\" і його підзавдання {count} буде остаточно видалено.",
"sessions.sidebar.dialogs.archiveSession.withOneSubtask": "\"{sessionTitle}\" і його підзавдання {count} буде заархівовано.",
@@ -581,7 +581,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.dialogs.archiveSession.single": "\"{sessionTitle}\" буде заархівовано.",
"sessions.sidebar.dialogs.neverAsk": "Більше не питати",
"sessions.sidebar.dialogs.cancel": "Скасувати",
"sessions.sidebar.dialogs.deleteSession.titleAction": "Видалити сесія",
"sessions.sidebar.dialogs.deleteSession.titleAction": "Видалити сесію",
"sessions.sidebar.dialogs.deleteSessions.titleAction": "Видалити сесії",
"sessions.sidebar.dialogs.deleteSessions.title": "Видалити сесії?",
"sessions.sidebar.dialogs.archiveSessions.title": "Архівувати сесії?",
@@ -661,7 +661,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.folderItem.deleteFolderAria": "Видалити папку {folderName}",
"sessions.sidebar.folderItem.emptyFolder": "Порожня папка",
"sessions.sidebar.sessionDialogs.ok": "OK",
"sessions.sidebar.sessionDialogs.linkedSessionSingle": "Пов’язаний сесія",
"sessions.sidebar.sessionDialogs.linkedSessionSingle": "Пов’язана сесія",
"sessions.sidebar.sessionDialogs.linkedSessionPlural": "Пов’язані сесії",
"sessions.sidebar.sessionDialogs.delete.note": "Каталоги worktree залишаються недоторканими. Підсесії, пов’язані з вибраними сесіями, також буде видалено.",
"sessions.sidebar.sessionDialogs.directory.errorSelectTitle": "Не вдалося вибрати каталог",
@@ -2062,7 +2062,6 @@ export const dict: Record<I18nKey, string> = {
"chat.statusRow.tasksTitle": "завдання",
"chat.statusRow.modelStatus": "{model} · {status}",
"chat.statusRow.summary.activeLeft": "Активних: {active} · залишилось: {left}",
"chat.statusRow.aborted": "Перервано",
"chat.revertIndicator.redo": "Повторити",
"chat.revertIndicator.redoAria": "Повторити — відновити відкочені повідомлення",
"chat.revertPopover.title": "Відкочено",
@@ -2140,7 +2139,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': 'Не вдалося залишити сесію btw',
"chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.",
"chat.container.sessionLoadError.title": "Не вдалося завантажити сесію",
"chat.container.sessionLoadError.description": "Перевірте з’єднання та спробуйте завантажити цю сесію ще раз.",
"chat.container.sessionLoadError.description": "Не вдалося отримати розмову — сервер може бути вимкнений або недосяжний. Нічого не втрачено; спробуй знову, коли він повернеться.",
"chat.container.sessionLoadError.authDescription": "Сесія завершилась, тож сервер відхилив запит. Увійди — і розмова завантажиться.",
"chat.container.sessionLoadError.retry": "Спробувати знову",
"sessions.sidebar.group.empty.loadingSessions": "Завантаження сесій…",
"sessions.sidebar.group.empty.loadFailed": "Не вдалося оновити сесії.",
@@ -2183,10 +2183,8 @@ export const dict: Record<I18nKey, string> = {
"chat.textSelection.title.commentOnSelection": "Коментувати виділене",
"chat.textSelection.comment.placeholder": "Додайте коментар за бажанням...",
"chat.textSelection.comment.attach": "Прикріпити",
"chat.textSelection.actions.newSession": "Нова сесія",
"chat.textSelection.actions.addToNotes": "Додати до нотаток",
"chat.textSelection.title.addToCurrentChat": "Додати до поточного чату",
"chat.textSelection.title.newSessionWithSelection": "Створити нову сесію із виділенням",
"chat.textSelection.title.saveInsightToNotes": "Зберегти вибраний текст у нотатках",
"chat.messageBody.actions.revertAria": "Повернутися до цього повідомлення",
"chat.messageBody.actions.revert": "Повернутися звідси",
@@ -2431,7 +2429,7 @@ export const dict: Record<I18nKey, string> = {
"chat.messageBody.subtask.title": "Делеговане завдання",
"chat.messageBody.subtask.hidePrompt": "Приховати промпт",
"chat.messageBody.subtask.showPrompt": "Показати промпт",
"chat.messageBody.subtask.openSession": "Відкрити сесія підзавдання",
"chat.messageBody.subtask.openSession": "Відкрити сесію підзавдання",
"chat.messageBody.shellCommand.title": "Команда оболонки",
"chat.messageBody.shellCommand.hideOutput": "Приховати вивід",
"chat.messageBody.shellCommand.showOutput": "Показати результат",
@@ -2673,6 +2671,9 @@ export const dict: Record<I18nKey, string> = {
"sessionAuth.error.passkeySignInCanceled": "Вхід за ключем доступу скасовано.",
"sessionAuth.error.enterPasswordForPasskey": "Введіть пароль, щоб додати ключ доступу.",
"sessionAuth.locked.tunnelTitle": "Потрібен доступ до тунелю",
"sessionAuth.expired.banner": "Сесія завершилась — увійди, щоб продовжити.",
"sessionAuth.expired.loginAction": "Увійти",
"sessionAuth.expired.sendBlocked": "Сесія завершилась — увійди, щоб надсилати повідомлення.",
"sessionAuth.locked.unlockTitle": "Розблокувати OpenChamber",
"sessionAuth.locked.tunnelDescription": "Відкрийте цей тунель за допомогою одноразового посилання для з’єднання з настільної програми.",
"sessionAuth.locked.passwordDescription": "Ця сесія захищена паролем.",
+5 -4
View File
@@ -2050,7 +2050,6 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.tasksTitle': '任务',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active} 个活跃 · 剩余 {left} 个',
'chat.statusRow.aborted': '已中止',
'chat.revertIndicator.redo': '重做',
'chat.revertIndicator.redoAria': '重做 — 恢复已撤回的消息',
'chat.revertPopover.title': '已撤回',
@@ -2128,7 +2127,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': '保留 btw 会话失败',
'chat.container.readOnlySubagentPromptBanner': '无法向子智能体会话发送提示。',
'chat.container.sessionLoadError.title': '无法加载会话',
'chat.container.sessionLoadError.description': '请检查连接,然后重新加载此会话。',
'chat.container.sessionLoadError.description': '无法获取对话——服务器可能已关闭或无法访问。内容没有丢失;等它恢复后重试即可。',
'chat.container.sessionLoadError.authDescription': '会话已过期,服务器拒绝了请求。登录后对话即会加载。',
'chat.container.sessionLoadError.retry': '重试',
'sessions.sidebar.group.empty.loadingSessions': '正在加载会话…',
'sessions.sidebar.group.empty.loadFailed': '无法刷新会话。',
@@ -2171,10 +2171,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.title.commentOnSelection': '评论所选内容',
'chat.textSelection.comment.placeholder': '添加可选评论...',
'chat.textSelection.comment.attach': '附加',
'chat.textSelection.actions.newSession': '新建会话',
'chat.textSelection.actions.addToNotes': '添加到笔记',
'chat.textSelection.title.addToCurrentChat': '添加到当前聊天',
'chat.textSelection.title.newSessionWithSelection': '使用选中内容创建新会话',
'chat.textSelection.title.saveInsightToNotes': '将选中文本保存到笔记',
'chat.messageBody.actions.revertAria': '回退到这条消息',
'chat.messageBody.actions.revert': '从此处回退',
@@ -2673,6 +2671,9 @@ export const dict: Record<I18nKey, string> = {
'sessionAuth.error.passkeySignInCanceled': 'Passkey 登录已取消。',
'sessionAuth.error.enterPasswordForPasskey': '请输入密码以添加 passkey。',
'sessionAuth.locked.tunnelTitle': '需要隧道访问',
'sessionAuth.expired.banner': '会话已过期——请登录以继续。',
'sessionAuth.expired.loginAction': '登录',
'sessionAuth.expired.sendBlocked': '会话已过期——请登录后再发送消息。',
'sessionAuth.locked.unlockTitle': '解锁 OpenChamber',
'sessionAuth.locked.tunnelDescription': '请使用桌面应用提供的一次性连接链接打开该隧道。',
'sessionAuth.locked.passwordDescription': '此会话受密码保护。',
+5 -4
View File
@@ -2054,7 +2054,6 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.tasksTitle': '任務',
'chat.statusRow.modelStatus': '{model} · {status}',
'chat.statusRow.summary.activeLeft': '{active} 個活躍 · 剩餘 {left} 個',
'chat.statusRow.aborted': '已中止',
'chat.revertIndicator.redo': '重做',
'chat.revertIndicator.redoAria': '重做 — 恢復已收回的訊息',
'chat.revertPopover.title': '已收回',
@@ -2132,7 +2131,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.promoteFailed': '保留 btw 工作階段失敗',
'chat.container.readOnlySubagentPromptBanner': '無法向子 Agent 會話傳送提示。',
'chat.container.sessionLoadError.title': '無法載入工作階段',
'chat.container.sessionLoadError.description': '請檢查連線,然後重新載入此工作階段。',
'chat.container.sessionLoadError.description': '無法取得對話——伺服器可能已關閉或無法連線。內容沒有遺失;待其恢復後再試即可。',
'chat.container.sessionLoadError.authDescription': '工作階段已過期,伺服器拒絕了請求。登入後對話即會載入。',
'chat.container.sessionLoadError.retry': '再試一次',
'sessions.sidebar.group.empty.loadingSessions': '正在載入工作階段…',
'sessions.sidebar.group.empty.loadFailed': '無法重新整理工作階段。',
@@ -2175,10 +2175,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.title.commentOnSelection': '對所選內容留言',
'chat.textSelection.comment.placeholder': '新增選填留言...',
'chat.textSelection.comment.attach': '附加',
'chat.textSelection.actions.newSession': '新增會話',
'chat.textSelection.actions.addToNotes': '加入筆記',
'chat.textSelection.title.addToCurrentChat': '加入目前聊天',
'chat.textSelection.title.newSessionWithSelection': '使用選取內容建立新會話',
'chat.textSelection.title.saveInsightToNotes': '將選取文字儲存到筆記',
'chat.messageBody.actions.revertAria': '收回到這條訊息',
'chat.messageBody.actions.revert': '從此處收回',
@@ -2677,6 +2675,9 @@ export const dict: Record<I18nKey, string> = {
'sessionAuth.error.passkeySignInCanceled': 'Passkey 登入已取消。',
'sessionAuth.error.enterPasswordForPasskey': '請輸入密碼以新增 passkey。',
'sessionAuth.locked.tunnelTitle': '需要 Tunnel 存取',
'sessionAuth.expired.banner': '工作階段已過期——請登入以繼續。',
'sessionAuth.expired.loginAction': '登入',
'sessionAuth.expired.sendBlocked': '工作階段已過期——請登入後再傳送訊息。',
'sessionAuth.locked.unlockTitle': '解鎖 OpenChamber',
'sessionAuth.locked.tunnelDescription': '請使用桌面應用程式提供的一次性連結開啟該 Tunnel。',
'sessionAuth.locked.passwordDescription': '此會話受密碼保護。',
+4 -4
View File
@@ -558,7 +558,7 @@ describe('updateDesktopSettings', () => {
});
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
@@ -584,7 +584,7 @@ describe('updateDesktopSettings', () => {
invalidateSettingsCache();
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
@@ -616,7 +616,7 @@ describe('updateDesktopSettings', () => {
invalidateSettingsCache();
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
@@ -647,7 +647,7 @@ describe('updateDesktopSettings', () => {
invalidateSettingsCache();
const syncedSettings: SettingsPayload[] = [];
const handleSettingsSynced = (event: Event) => {
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
};
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
+19 -6
View File
@@ -199,11 +199,23 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
setOrRemoveLocalStorage('sttLanguage', typeof settings.sttLanguage === 'string' ? settings.sttLanguage : null);
};
const dispatchSettingsSynced = (settings: DesktopSettings): void => {
export interface SettingsSyncedDetail {
settings: DesktopSettings;
/** Whether listeners may adopt cross-window workspace pointers
(activeProjectId / lastDirectory). True only for a bootstrap-grade sync:
the settings document is shared by every window of this server, so a
mid-session reconciliation adopting them would hijack this window's
workspace with another window's choice. */
adoptWorkspace: boolean;
}
const dispatchSettingsSynced = (settings: DesktopSettings, adoptWorkspace: boolean): void => {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent<DesktopSettings>('openchamber:settings-synced', { detail: settings }));
window.dispatchEvent(new CustomEvent<SettingsSyncedDetail>('openchamber:settings-synced', {
detail: { settings, adoptWorkspace },
}));
};
type SettingsSaveState = 'idle' | 'saving' | 'error';
@@ -1841,7 +1853,8 @@ export const invalidateSettingsCache = (): void => {
_settingsCache = null;
};
export const syncDesktopSettings = async (): Promise<void> => {
export const syncDesktopSettings = async (options?: { adoptWorkspace?: boolean }): Promise<void> => {
const adoptWorkspace = options?.adoptWorkspace !== false;
if (typeof window === 'undefined') {
return;
}
@@ -1970,7 +1983,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
if (!isSettingsRuntimeContextCurrent(context)) return;
}
dispatchSettingsSynced(authoritativeSettings);
dispatchSettingsSynced(authoritativeSettings, adoptWorkspace);
};
try {
@@ -2013,7 +2026,7 @@ async function _flushSettingsUpdate(): Promise<void> {
if (updated) {
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
applyDesktopUiPreferences(reconciled);
dispatchSettingsSynced(reconciled);
dispatchSettingsSynced(reconciled, false);
_settingsCache = null;
}
dispatchSettingsSaveState(updated ? 'saved' : 'error');
@@ -2047,7 +2060,7 @@ async function _flushSettingsUpdate(): Promise<void> {
if (updated) {
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
applyDesktopUiPreferences(reconciled);
dispatchSettingsSynced(reconciled);
dispatchSettingsSynced(reconciled, false);
dispatchSettingsSaveState('saved');
// Invalidate GET cache so next read sees the fresh data
_settingsCache = null;
@@ -8,7 +8,6 @@ export interface QuotaProviderMeta {
export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
{ id: 'claude', name: 'Claude' },
{ id: 'codex', name: 'Codex' },
{ id: 'command-code', name: 'Command Code' },
{ id: 'cursor', name: 'Cursor' },
{ id: 'github-copilot', name: 'GitHub Copilot' },
{ id: 'google', name: 'Google' },
+126
View File
@@ -0,0 +1,126 @@
import { create } from 'zustand';
// Proactive detection of an expired OpenChamber client session (cookie or
// bearer). There is no polling: every HTTP response already funnels through
// runtimeFetch, and this module only classifies what passes by. A 401 alone
// is NOT proof — OpenCode proxies provider errors through the same routes, so
// a dead Anthropic key also surfaces as 401. Every suspicion is therefore
// confirmed with one debounced GET /auth/session before the state flips.
//
// Consumers: the web/hosted banner (AuthExpiredBanner), the send guard in the
// composer, and the native mobile app, which feeds the signal into its own
// connection orchestration instead of showing the shared banner.
export type AuthSessionState = 'ok' | 'expired' | 'reauthenticating';
interface AuthSessionStore {
state: AuthSessionState;
/** Set only by the confirmed classifier or an explicit auth failure. */
markExpired: () => void;
markReauthenticating: () => void;
markAuthenticated: () => void;
}
export const useAuthSessionStore = create<AuthSessionStore>((set) => ({
state: 'ok',
markExpired: () => set((current) => (current.state === 'expired' ? current : { state: 'expired' })),
markReauthenticating: () => set({ state: 'reauthenticating' }),
markAuthenticated: () => set({ state: 'ok' }),
}));
// One confirm probe per window: parallel 401s from a burst of requests must
// not turn into a probe storm, and a provider-side 401 that keeps repeating
// must not re-probe on every retry.
const CONFIRM_PROBE_MIN_INTERVAL_MS = 15_000;
// Focus revalidation only bothers the server when the tab was away long
// enough for a 12h/7d session to plausibly have died.
const FOCUS_REVALIDATE_MIN_INTERVAL_MS = 5 * 60_000;
let lastProbeAt = 0;
let probeInFlight = false;
// Paths where a 401 is part of a normal flow (wrong password on login, a
// pairing redeem, the confirm probe itself) rather than evidence of expiry.
const isExcludedAuthPath = (url: string): boolean => (
url.includes('/auth/session') || url.includes('/api/client-auth/')
);
const isClassifiablePath = (url: string): boolean => {
const path = url.startsWith('/') ? url : (() => {
try {
return new URL(url).pathname;
} catch {
return '';
}
})();
if (!path.startsWith('/api/') && !path.startsWith('/auth/')) return false;
return !isExcludedAuthPath(path);
};
const confirmSessionExpired = async (): Promise<void> => {
if (probeInFlight) return;
probeInFlight = true;
try {
// Deferred import: runtime-fetch classifies through this module, and the
// probe deliberately re-enters it (its /auth/session path is excluded).
const { runtimeFetch } = await import('./runtime-fetch');
const response = await runtimeFetch('/auth/session', { credentials: 'include' });
if (response.status === 401) {
useAuthSessionStore.getState().markExpired();
return;
}
if (response.ok) {
// The suspicious 401 came from deeper in the chain (a provider key, an
// upstream OpenCode instance) — the OpenChamber session is alive.
const { state, markAuthenticated } = useAuthSessionStore.getState();
if (state === 'expired') markAuthenticated();
}
} catch {
// Transport failure is connectivity, not authentication; the connection
// status machinery owns that story.
} finally {
probeInFlight = false;
}
};
/**
* Called by runtimeFetch for every response. Cheap by design: everything but
* a 401 on a classifiable path returns immediately.
*/
export const observeRuntimeAuthResponse = (url: string, status: number): void => {
if (status !== 401) return;
if (useAuthSessionStore.getState().state === 'expired') return;
if (!isClassifiablePath(url)) return;
const now = Date.now();
if (now - lastProbeAt < CONFIRM_PROBE_MIN_INTERVAL_MS) return;
lastProbeAt = now;
void confirmSessionExpired();
};
let watchInstalled = false;
/**
* Revalidates the session when the tab regains visibility after a long
* absence the "laptop woke up, everything looks alive, first click fails"
* case. One request per wake, nothing periodic.
*/
export const installAuthSessionFocusWatch = (): void => {
// Callers are React effects, so a document always exists here.
if (watchInstalled) return;
watchInstalled = true;
let lastConfirmedAt = Date.now();
const revalidate = () => {
if (useAuthSessionStore.getState().state !== 'ok') return;
const now = Date.now();
if (now - lastConfirmedAt < FOCUS_REVALIDATE_MIN_INTERVAL_MS) return;
lastConfirmedAt = now;
lastProbeAt = now;
void confirmSessionExpired();
};
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') revalidate();
});
// App switches on desktop can refocus the window without a visibility
// change; both signals share one throttle, so a wake costs one request.
window.addEventListener('focus', revalidate);
};
+9
View File
@@ -1,6 +1,7 @@
import { getActiveRelayTunnel } from './relay/runtime-tunnel';
import { TUNNEL_PARSE_BASE } from './relay/tunnel-payloads';
import { buildRuntimeAuthHeaders } from './runtime-auth';
import { observeRuntimeAuthResponse } from './runtime-auth-expiry';
import { getRuntimeUrlResolver, type RuntimeUrlQuery } from './runtime-url';
export interface RuntimeFetchOptions extends RequestInit {
@@ -294,6 +295,14 @@ export const runtimeFetch = async (input: string | URL | Request, init: RuntimeF
).toUpperCase();
}
// Session-expiry classification rides on responses that already flow
// through here; only the status is read, never the body.
const rawFetch = doFetch;
doFetch = () => rawFetch().then((response) => {
observeRuntimeAuthResponse(url, response.status);
return response;
});
// A Request always carries a (possibly default) signal; treat any Request, or
// an explicit init.signal, as "has signal" and skip coalescing for safety.
const hasSignal = requestInit.signal != null || input instanceof Request;
+7
View File
@@ -213,6 +213,13 @@ Each of them therefore keeps two things:
- a flat mirror (`agents`, `commands`, `skills`, `mcpServers`, `providers`) that
tracks the **active** project only.
Thinking variants keep the effective value in `currentVariant` so existing send
paths capture a stable configuration. The transient `currentVariantSelection`
distinguishes automatic initialization from a picker or shortcut choosing an
explicit override or `Default`; returning to `Default` restores its inherited
effective value. Only explicit overrides are stored in the per-session
selection store.
Every loader and mutation takes an explicit directory; omitting it means the
active project, which is what non-Settings callers pass. A load for another
directory writes the map and leaves the mirror alone, so browsing another
@@ -268,6 +268,7 @@ describe('useConfigStore provider persistence', () => {
currentProviderId: '',
currentModelId: '',
currentVariant: undefined,
currentVariantSelection: { override: undefined, inherited: undefined },
selectedProviderId: '',
currentAgentName: undefined,
agents: [],
@@ -525,6 +526,60 @@ describe('useConfigStore provider persistence', () => {
expect(state.directoryScoped[DIRECTORY]?.currentVariant).toBe('high');
});
test('cycleCurrentVariant reaches Default, low, and medium from inherited high', () => {
useConfigStore.setState({
providers: [provider('openai', 'gpt-5.6-sol', { none: {}, low: {}, medium: {}, high: {}, xhigh: {}, max: {} })],
currentProviderId: 'openai',
currentModelId: 'gpt-5.6-sol',
currentVariant: 'high',
currentVariantSelection: { override: undefined, inherited: 'high' },
directoryScoped: {},
});
const expectedVariants = ['xhigh', 'max', undefined, 'none', 'low', 'medium', 'high'];
for (const expectedVariant of expectedVariants) {
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(expectedVariant);
expect(useConfigStore.getState().currentVariantSelection.override).toBe(expectedVariant ?? null);
}
useConfigStore.getState().setCurrentVariantOverride('max', 'high');
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
expect(useConfigStore.getState().currentVariant).toBe('high');
expect(useConfigStore.getState().currentVariantSelection).toEqual({ override: null, inherited: 'high' });
});
test('cycleCurrentVariant toggles a single variant with Default', () => {
useConfigStore.setState({
providers: [provider('openai', 'single', { high: {} })],
currentProviderId: 'openai',
currentModelId: 'single',
currentVariant: 'high',
currentVariantSelection: { override: null, inherited: 'high' },
directoryScoped: {},
});
expect(useConfigStore.getState().cycleCurrentVariant()).toBe('high');
expect(useConfigStore.getState().currentVariantSelection.override).toBe('high');
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
expect(useConfigStore.getState().currentVariantSelection.override).toBeNull();
expect(useConfigStore.getState().currentVariant).toBe('high');
});
test('an unavailable explicit variant cycles back to Default', () => {
useConfigStore.setState({
providers: [provider('openai', 'changed', { low: {}, high: {} })],
currentProviderId: 'openai',
currentModelId: 'changed',
currentVariant: 'removed',
currentVariantSelection: { override: 'removed', inherited: 'low' },
directoryScoped: {},
});
expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined);
expect(useConfigStore.getState().currentVariant).toBe('low');
expect(useConfigStore.getState().currentVariantSelection.override).toBeNull();
});
test('setAgent prefers saved and agent variants before settings default', () => {
const sessionId = 'ses_agent_saved_variant';
useSessionUIStore.setState({ currentSessionId: sessionId });
@@ -700,6 +755,29 @@ describe('useConfigStore provider persistence', () => {
expect(state.currentVariant).toBe('high');
});
test('a fresh session applies the settings thinking level instead of the previous override', () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })],
agents: [testAgent('build')],
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentVariant: 'low',
currentVariantSelection: { override: 'low', inherited: 'high' },
settingsDefaultModel: 'openai/gpt-5.5',
settingsDefaultVariant: 'high',
selectionSource: 'manual',
directoryScoped: {},
});
useConfigStore.getState().applyDefaultModelAgentSelection();
const state = useConfigStore.getState();
expect(state.currentVariant).toBe('high');
expect(state.currentVariantSelection).toEqual({ override: 'high', inherited: 'high' });
expect(state.directoryScoped[DIRECTORY]?.currentVariant).toBe('high');
});
test('a thinking level the project model does not offer is ignored', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
@@ -1036,6 +1114,8 @@ describe('useConfigStore provider persistence', () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
selectionSource: 'manual',
currentVariant: 'high',
currentVariantSelection: { override: 'high', inherited: 'medium' },
opencodeDefaultAgent: 'active-default',
opencodeDefaultModel: 'active/model',
directoryScoped: {
@@ -1057,6 +1137,7 @@ describe('useConfigStore provider persistence', () => {
agents: [testAgent('other-agent')],
currentProviderId: 'other',
currentModelId: 'other-model',
currentVariant: 'low',
currentAgentName: 'other-agent',
selectedProviderId: 'other',
agentModelSelections: {},
@@ -1076,6 +1157,7 @@ describe('useConfigStore provider persistence', () => {
expect(state.selectionSource).toBe('auto');
expect(state.opencodeDefaultAgent).toBe('other-default');
expect(state.opencodeDefaultModel).toBe('other/model');
expect(state.currentVariantSelection).toEqual({ override: undefined, inherited: 'low' });
});
test('sync config without defaults clears stored OpenCode defaults without changing manual selection', () => {
+49 -23
View File
@@ -885,6 +885,11 @@ interface DirectoryScopedConfig {
selectionSource?: "auto" | "manual";
}
type CurrentVariantSelection = {
override: string | null | undefined;
inherited: string | undefined;
};
/**
* Lift the active directory's cached provider/agent snapshot into the top-level
* fields the pickers read (`providers`, `agents`, selections), so a cold start
@@ -1006,6 +1011,7 @@ interface ConfigStore {
currentProviderId: string;
currentModelId: string;
currentVariant: string | undefined;
currentVariantSelection: CurrentVariantSelection;
currentAgentName: string | undefined;
selectedProviderId: string;
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
@@ -1098,7 +1104,8 @@ interface ConfigStore {
setProvider: (providerId: string) => void;
setModel: (modelId: string) => void;
setCurrentVariant: (variant: string | undefined) => void;
cycleCurrentVariant: () => void;
setCurrentVariantOverride: (override: string | null | undefined, inherited: string | undefined) => void;
cycleCurrentVariant: () => string | undefined;
getCurrentModelVariants: () => string[];
setAgent: (agentName: string | undefined) => void;
applyDefaultModelAgentSelection: (options?: { projectDefaultModel?: string; projectDefaultVariant?: string }) => void;
@@ -1171,6 +1178,7 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: "",
currentModelId: "",
currentVariant: undefined,
currentVariantSelection: { override: undefined, inherited: undefined },
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
@@ -1437,6 +1445,7 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: snapshot.currentProviderId,
currentModelId: snapshot.currentModelId,
currentVariant: snapshot.currentVariant,
currentVariantSelection: { override: undefined, inherited: snapshot.currentVariant },
currentAgentName: snapshot.currentAgentName,
selectedProviderId: snapshot.selectedProviderId,
agentModelSelections: snapshot.agentModelSelections,
@@ -1453,6 +1462,7 @@ export const useConfigStore = create<ConfigStore>()(
agents: [],
currentProviderId: "",
currentModelId: "",
currentVariantSelection: { override: undefined, inherited: undefined },
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
@@ -1847,13 +1857,22 @@ export const useConfigStore = create<ConfigStore>()(
},
setCurrentVariant: (variant: string | undefined) => {
get().setCurrentVariantOverride(undefined, variant);
},
setCurrentVariantOverride: (override, inherited) => {
set((state) => {
if (state.currentVariant === variant) {
const currentVariant = override ?? inherited;
if (
state.currentVariant === currentVariant
&& state.currentVariantSelection.override === override
&& state.currentVariantSelection.inherited === inherited
) {
return state;
}
const directoryKey = state.activeDirectoryKey;
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
const baseSnapshot = state.directoryScoped[directoryKey] ?? {
providers: state.providers,
agents: state.agents,
currentProviderId: state.currentProviderId,
@@ -1865,18 +1884,17 @@ export const useConfigStore = create<ConfigStore>()(
defaultProviders: state.defaultProviders,
};
const nextSnapshot: DirectoryScopedConfig = {
...baseSnapshot,
currentVariant: variant,
selectionSource: "manual",
};
return {
currentVariant: variant,
currentVariant,
currentVariantSelection: { override, inherited },
selectionSource: "manual",
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
[directoryKey]: {
...baseSnapshot,
currentVariant,
selectionSource: "manual",
},
},
};
});
@@ -1894,22 +1912,26 @@ export const useConfigStore = create<ConfigStore>()(
cycleCurrentVariant: () => {
const variantKeys = get().getCurrentModelVariants();
if (variantKeys.length === 0) {
return;
return undefined;
}
const current = get().currentVariant;
if (!current) {
get().setCurrentVariant(variantKeys[0]);
return;
const state = get();
const currentOverride = state.currentVariantSelection.override;
const inheritedVariant = state.currentVariantSelection.inherited ?? state.currentVariant;
const currentVariant = currentOverride === undefined
? state.currentVariant
: currentOverride;
let nextOverride: string | null;
if (currentVariant === null || currentVariant === undefined) {
nextOverride = variantKeys[0];
} else {
const index = variantKeys.indexOf(currentVariant);
nextOverride = index >= 0 ? (variantKeys[index + 1] ?? null) : null;
}
const index = variantKeys.indexOf(current);
if (index === -1 || index === variantKeys.length - 1) {
get().setCurrentVariant(undefined);
return;
}
get().setCurrentVariant(variantKeys[index + 1]);
get().setCurrentVariantOverride(nextOverride, inheritedVariant);
return nextOverride ?? undefined;
},
setSelectedProvider: (providerId: string) => {
@@ -2659,6 +2681,10 @@ export const useConfigStore = create<ConfigStore>()(
nextState.currentProviderId = resolvedProviderId;
nextState.currentModelId = resolvedModelId;
nextState.currentVariant = resolvedVariant;
nextState.currentVariantSelection = {
override: resolvedVariant,
inherited: resolvedVariant,
};
}
return nextState;
+3 -3
View File
@@ -1,8 +1,8 @@
import { create } from 'zustand';
import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isDesktopShell, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop';
import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isDesktopShell, type InstalledDesktopAppInfo } from '@/lib/desktop';
import { OPEN_IN_APPS, DEFAULT_OPEN_IN_APP_ID, OPEN_IN_ALWAYS_AVAILABLE_APP_IDS, getOpenInAppById, getPlatformOpenInApp, type OpenInApp } from '@/lib/openInApps';
import { updateDesktopSettings } from '@/lib/persistence';
import { type SettingsSyncedDetail, updateDesktopSettings } from '@/lib/persistence';
export type OpenInAppOption = OpenInApp & {
iconDataUrl?: string;
@@ -160,7 +160,7 @@ export const useOpenInAppsStore = create<OpenInAppsState>()((set, get) => ({
void loadInstalledApps();
const settingsHandler = (event: Event) => {
const detail = (event as CustomEvent<DesktopSettings>).detail;
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail?.settings;
const nextId = detail
&& typeof detail.openInAppId === 'string'
&& detail.openInAppId.length > 0
@@ -18,6 +18,39 @@ describe("useProjectsStore settings synchronization", () => {
expect(useProjectsStore.getState().activeProjectId).toBe(null)
expect(useProjectsStore.getState().manualProjectOrder).toEqual([])
})
test("a reconcile sync never adopts another window's active project", () => {
// Ids are path-derived inside the store's sanitizer, so seed real ones by
// bootstrapping once and reading them back.
const raw = { projects: [{ path: "/repo-a" }, { path: "/repo-b" }] } as DesktopSettings
useProjectsStore.getState().synchronizeFromSettings(raw)
const [first, second] = useProjectsStore.getState().projects
useProjectsStore.setState({ activeProjectId: first.id })
// The shared settings document carries window B's pointer; outside a
// bootstrap this window keeps its own.
useProjectsStore.getState().synchronizeFromSettings(
{ ...raw, activeProjectId: second.id } as DesktopSettings,
{ adoptActiveProject: false },
)
expect(useProjectsStore.getState().activeProjectId).toBe(first.id)
// Unless its own project vanished from the list — then the incoming
// pointer is better than a dangling one.
useProjectsStore.getState().synchronizeFromSettings(
{ projects: [{ path: "/repo-b" }], activeProjectId: second.id } as DesktopSettings,
{ adoptActiveProject: false },
)
expect(useProjectsStore.getState().activeProjectId).toBe(second.id)
// A bootstrap sync adopts as before.
useProjectsStore.getState().synchronizeFromSettings(raw)
useProjectsStore.setState({ activeProjectId: first.id })
useProjectsStore.getState().synchronizeFromSettings(
{ ...raw, activeProjectId: second.id } as DesktopSettings,
)
expect(useProjectsStore.getState().activeProjectId).toBe(second.id)
})
})
describe("useProjectsStore selection identity", () => {
+28 -15
View File
@@ -4,7 +4,7 @@ import { opencodeClient } from '@/lib/opencode/client';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import type { ProjectEntry } from '@/lib/api/types';
import type { DesktopSettings } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { type SettingsSyncedDetail, updateDesktopSettings } from '@/lib/persistence';
import { createProjectIdFromPath } from '@/lib/projectId';
import { getDeferredSafeStorage } from './utils/safeStorage';
import { useDirectoryStore } from './useDirectoryStore';
@@ -68,7 +68,7 @@ interface ProjectsStore {
reorderProjects: (fromIndex: number, toIndex: number) => void;
resetForRuntimeSwitch: () => void;
validateProjectPath: (path: string) => ProjectPathValidationResult;
synchronizeFromSettings: (settings: DesktopSettings) => void;
synchronizeFromSettings: (settings: DesktopSettings, options?: { adoptActiveProject?: boolean }) => void;
syncVSCodeWorkspaceFolders: (folders: VSCodeWorkspaceFolderConfig[], activePath?: string | null) => ProjectEntry | null;
getActiveProject: () => ProjectEntry | null;
}
@@ -809,7 +809,7 @@ export const useProjectsStore = create<ProjectsStore>()(
const payload = (await response.json().catch(() => null)) as { settings?: DesktopSettings } | null;
if (payload?.settings) {
get().synchronizeFromSettings(payload.settings);
get().synchronizeFromSettings(payload.settings, { adoptActiveProject: false });
}
return { ok: true };
} catch (error) {
@@ -838,7 +838,7 @@ export const useProjectsStore = create<ProjectsStore>()(
const payload = (await response.json().catch(() => null)) as { settings?: DesktopSettings } | null;
if (payload?.settings) {
get().synchronizeFromSettings(payload.settings);
get().synchronizeFromSettings(payload.settings, { adoptActiveProject: false });
}
return { ok: true };
} catch (error) {
@@ -874,7 +874,7 @@ export const useProjectsStore = create<ProjectsStore>()(
}
if (payload?.settings) {
get().synchronizeFromSettings(payload.settings);
get().synchronizeFromSettings(payload.settings, { adoptActiveProject: false });
}
return {
@@ -924,32 +924,43 @@ export const useProjectsStore = create<ProjectsStore>()(
set({ projects, activeProjectId: nextActiveProjectId, manualProjectOrder: [] });
},
synchronizeFromSettings: (settings: DesktopSettings) => {
synchronizeFromSettings: (settings: DesktopSettings, options?: { adoptActiveProject?: boolean }) => {
if (isVSCodeProjectsRuntime) {
return;
}
const adoptActiveProject = options?.adoptActiveProject !== false;
const incomingProjects = sanitizeProjects(settings.projects ?? []);
const incomingActive = typeof settings.activeProjectId === 'string' && settings.activeProjectId.trim()
? settings.activeProjectId.trim()
: null;
const current = get();
const incomingIds = new Set(incomingProjects.map((p) => p.id));
// The settings document is shared by every window on this server, so
// outside a bootstrap sync the incoming active pointer is just another
// window's choice — the project LIST still reconciles, but this
// window's active project stays its own while it remains valid.
const nextActive = adoptActiveProject
? incomingActive
: (current.activeProjectId && incomingIds.has(current.activeProjectId)
? current.activeProjectId
: incomingActive);
const projectsChanged = JSON.stringify(current.projects) !== JSON.stringify(incomingProjects);
const activeChanged = current.activeProjectId !== incomingActive;
const activeChanged = current.activeProjectId !== nextActive;
if (!projectsChanged && !activeChanged) {
return;
}
const incomingIds = new Set(incomingProjects.map((p) => p.id));
const cleanedOrder = get().manualProjectOrder.filter((id) => incomingIds.has(id));
set({ projects: incomingProjects, activeProjectId: incomingActive, manualProjectOrder: cleanedOrder });
cacheProjects(incomingProjects, incomingActive);
set({ projects: incomingProjects, activeProjectId: nextActive, manualProjectOrder: cleanedOrder });
cacheProjects(incomingProjects, nextActive);
persistManualProjectOrder(cleanedOrder);
if (incomingActive) {
const activeProject = incomingProjects.find((project) => project.id === incomingActive);
if (activeChanged && nextActive) {
const activeProject = incomingProjects.find((project) => project.id === nextActive);
if (activeProject) {
opencodeClient.setDirectory(activeProject.path);
useDirectoryStore.getState().setDirectory(activeProject.path, { showOverlay: false });
@@ -1005,9 +1016,11 @@ export const useProjectsStore = create<ProjectsStore>()(
if (typeof window !== 'undefined') {
window.addEventListener('openchamber:settings-synced', (event: Event) => {
const detail = (event as CustomEvent<DesktopSettings>).detail;
if (detail && typeof detail === 'object') {
useProjectsStore.getState().synchronizeFromSettings(detail);
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail;
if (detail && typeof detail === 'object' && detail.settings) {
useProjectsStore.getState().synchronizeFromSettings(detail.settings, {
adoptActiveProject: detail.adoptWorkspace,
});
}
});
}
+16 -18
View File
@@ -393,7 +393,9 @@ const touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanel
const upsertContextPanelTab = (
current: ContextPanelDirectoryState,
descriptor: ContextPanelTabDescriptor,
options?: { reveal?: boolean },
): ContextPanelDirectoryState => {
const reveal = options?.reveal !== false;
const nextTab = createContextPanelTab(descriptor);
// A real file tab replaces the empty editor placeholder ('file' with no
// target) that the rail can open before any file is picked.
@@ -418,12 +420,18 @@ const upsertContextPanelTab = (
}
: tab));
const activeTabId = nextTab.id;
// A background upsert (an agent working a page) keeps the panel exactly as
// the user left it: closed stays closed, and whatever tab they were on
// stays active. The tab still exists — panes are kept mounted regardless of
// visibility — so agent control and a later manual open both find it.
const activeTabId = reveal
? nextTab.id
: current.activeTabId ?? nextTab.id;
const clampedTabs = clampContextPanelTabs(tabs, CONTEXT_PANEL_MAX_TABS, activeTabId);
return {
...current,
isOpen: true,
isOpen: reveal ? true : current.isOpen,
tabs: clampedTabs,
activeTabId: resolveActiveContextPanelTabID(clampedTabs, activeTabId),
touchedAt: Date.now(),
@@ -806,14 +814,13 @@ interface UIStore {
toggleContextEditorTree: () => void;
setContextEditorTreeWidth: (width: number) => void;
openContextSurface: (directory: string, mode: ContextPanelMode) => void;
openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor) => void;
openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor, options?: { reveal?: boolean }) => void;
openContextDiff: (directory: string, filePath: string, staged?: boolean, scope?: PendingDiffScope | null) => void;
openContextFile: (directory: string, filePath: string) => void;
openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void;
openContextOverview: (directory: string) => void;
openContextPlan: (directory: string) => void;
openContextPreview: (directory: string, url: string) => void;
openContextBrowser: (directory: string, url?: string) => void;
openContextBrowser: (directory: string, url?: string, options?: { reveal?: boolean }) => void;
openNewContextBrowserTab: (directory: string) => void;
setContextPanelTabTargetPath: (directory: string, tabID: string, targetPath: string) => void;
setActiveContextPanelTab: (directory: string, tabID: string) => void;
@@ -1239,7 +1246,7 @@ export const useUIStore = create<UIStore>()(
state.openContextPanelTab(normalizedDirectory, { mode });
},
openContextPanelTab: (directory, tab) => {
openContextPanelTab: (directory, tab, options) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
@@ -1250,7 +1257,7 @@ export const useUIStore = create<UIStore>()(
const current = touchContextPanelState(prev);
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: upsertContextPanelTab(current, tab),
[normalizedDirectory]: upsertContextPanelTab(current, tab, options),
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
@@ -1313,15 +1320,6 @@ export const useUIStore = create<UIStore>()(
get().openContextPanelTab(normalizedDirectory, { mode: 'context' });
},
openContextPlan: (directory) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) {
return;
}
get().openContextPanelTab(normalizedDirectory, { mode: 'plan' });
},
openContextPreview: (directory, url) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedUrl = (url || '').trim();
@@ -1351,7 +1349,7 @@ export const useUIStore = create<UIStore>()(
label: null,
});
},
openContextBrowser: (directory, url = '') => {
openContextBrowser: (directory, url = '', options) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory || isVSCodeRuntime()) return;
const targetUrl = typeof url === 'string' && url.trim().length > 0 ? url.trim() : '';
@@ -1360,7 +1358,7 @@ export const useUIStore = create<UIStore>()(
targetPath: targetUrl,
dedupeKey: targetUrl || 'browser',
label: null,
});
}, options);
},
setContextPanelTabTargetPath: (directory, tabID, targetPath) => {
+27
View File
@@ -142,6 +142,23 @@
min-height: 0;
}
/* -webkit-fill-available above is a pre-dvh iOS Safari fix. On Android
Chrome it freezes the root at the pre-keyboard height: when
interactive-widget=resizes-content shrinks the viewport, the document
stays taller than the screen and (with overflow hidden) the composer's
bottom is clipped behind the keyboard with no way to scroll to it.
Every dvh-capable browser gets the dynamic height instead; the legacy
fallback above keeps serving browsers without dvh. */
@supports (height: 100dvh) {
:root.mobile-pointer:not(.desktop-runtime) {
height: 100dvh;
}
:root.mobile-pointer:not(.desktop-runtime) .flex.flex-col.h-screen {
height: 100dvh;
}
}
/* Fix main content area */
:root.mobile-pointer:not(.desktop-runtime) .flex-1.overflow-hidden {
min-height: 0;
@@ -216,6 +233,16 @@
min-height: -webkit-fill-available;
}
/* Same Android-keyboard clipping fix as above: dvh-capable browsers
must not keep a frozen -webkit-fill-available minimum. */
@supports (min-height: 100dvh) {
:root.device-mobile:not(.desktop-runtime) .flex.flex-col.h-screen,
:root.device-tablet:not(.desktop-runtime) .flex.flex-col.h-screen,
:root.mobile-pointer:not(.desktop-runtime) .flex.flex-col.h-screen {
min-height: 100dvh;
}
}
/* Prevent content overlap in iOS */
:root.device-mobile:not(.desktop-runtime) .flex-1.overflow-hidden,
:root.device-tablet:not(.desktop-runtime) .flex-1.overflow-hidden,
@@ -4,6 +4,8 @@ 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]> = []
const savedVariantCalls: Array<string | undefined> = []
let configVariantOverride: string | null | undefined
// Sync's session→directory index. `createSession` writes it, and directory
// resolution reads it as the authoritative source, so the mock has to keep one.
const sessionDirectoryRegistry = new Map<string, string>()
@@ -96,6 +98,9 @@ mock.module("@/stores/useConfigStore", () => ({
useConfigStore: {
getState: () => ({
currentAgentName: "agent-default",
currentProviderId: "provider",
currentModelId: "model",
currentVariantSelection: { override: configVariantOverride, inherited: "high" },
agents: [],
activateDirectory: mock(async () => undefined),
applyDefaultModelAgentSelection: mock(() => undefined),
@@ -170,7 +175,9 @@ mock.module("../selection-store", () => ({
saveSessionModelSelection: () => undefined,
saveSessionAgentSelection: () => undefined,
saveAgentModelForSession: () => undefined,
saveAgentModelVariantForSession: () => undefined,
saveAgentModelVariantForSession: (_sessionId: string, _agent: string, _provider: string, _model: string, variant: string | undefined) => {
savedVariantCalls.push(variant)
},
getSessionAgentSelection: () => null,
getSessionModelSelection: () => null,
getAgentModelForSession: () => null,
@@ -348,6 +355,8 @@ describe("issue 2039 draft auto-accept", () => {
createSessionCalls.length = 0
sessionDirectoryRegistry.clear()
permissionAutoAcceptCalls.length = 0
savedVariantCalls.length = 0
configVariantOverride = undefined
createdSessionDirectory = undefined
useSessionUIStore.setState({
@@ -384,6 +393,29 @@ describe("issue 2039 draft auto-accept", () => {
expect(useSessionUIStore.getState().currentSessionId).toBe("ses_issue_2039")
})
test("stores only an explicit draft variant as the session override", async () => {
useSessionUIStore.getState().openNewSessionDraft()
await materializeOpenDraftSession({
providerID: "provider",
modelID: "model",
agent: "agent-default",
variant: "high",
})
expect(savedVariantCalls).toEqual([undefined])
configVariantOverride = "high"
useSessionUIStore.getState().openNewSessionDraft()
await materializeOpenDraftSession({
providerID: "provider",
modelID: "model",
agent: "agent-default",
variant: "high",
})
expect(savedVariantCalls).toEqual([undefined, "high"])
})
test("does not apply draft auto-accept after the draft is closed", async () => {
useSessionUIStore.getState().openNewSessionDraft()
useSessionUIStore.getState().setDraftPermissionAutoAcceptEnabled(true)
+6 -1
View File
@@ -840,13 +840,18 @@ export async function materializeOpenDraftSession(selection: {
})
const effectiveDraftAgent = trimmedAgent ?? configState.currentAgentName
const variantOverride = configState.currentProviderId === selection.providerID
&& configState.currentModelId === selection.modelID
&& configState.currentAgentName === effectiveDraftAgent
? configState.currentVariantSelection.override ?? undefined
: selection.variant
useSelectionStore.getState().saveSessionModelSelection(created.id, selection.providerID, selection.modelID)
if (effectiveDraftAgent) {
useSelectionStore.getState().saveSessionAgentSelection(created.id, effectiveDraftAgent)
useSelectionStore.getState().saveAgentModelForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID)
useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID, selection.variant)
useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID, variantOverride)
}
store.initializeNewOpenChamberSession(created.id, configState.agents ?? [])
-1
View File
@@ -1,7 +1,6 @@
export type QuotaProviderId =
| 'openai'
| 'codex'
| 'command-code'
| 'cursor'
| 'claude'
| 'github-copilot'