fix(ui): preserve VS Code themes during settings broadcasts

This commit is contained in:
Bohdan Triapitsyn
2026-09-03 12:41:59 +03:00
141 changed files with 3435 additions and 286 deletions
+6 -1
View File
@@ -348,7 +348,12 @@ function App({ apis }: AppProps) {
void refreshGitHubAuthStatus(apis.github, { force: true });
void refreshLinearAuthStatus(apis.linear, { force: true });
}, [apis.github, apis.linear, embeddedSessionChat, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
// `apis` is the same object across an instance switch, so without the epoch
// this ran once for the whole app session and both statuses kept describing
// whichever instance happened to be connected at startup. `isConnected` is
// here to re-ask, not to gate: both integrations answer independently of
// OpenCode, but a switch can race the transport and the retry is deduped.
}, [apis.github, apis.linear, embeddedSessionChat, isConnected, refreshGitHubAuthStatus, refreshLinearAuthStatus, runtimeEndpointEpoch]);
useAppFontEffects();
+122 -3
View File
@@ -5,7 +5,9 @@ import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel';
import { BranchSelector } from '@/components/views/git/BranchSelector';
import { CommitSection } from '@/components/views/git/CommitSection';
import { DirtyBranchSwitchDialog } from '@/components/views/git/DirtyBranchSwitchDialog';
import { SyncActions } from '@/components/views/git/SyncActions';
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
@@ -19,6 +21,7 @@ import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
import {
useGitStore,
useGitStatus,
useGitBranches,
useIsGitRepo,
useGitLoadingStatus,
} from '@/stores/useGitStore';
@@ -65,6 +68,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null);
const currentDirectory = gitDirectory ?? rootDirectory;
const status = useGitStatus(currentDirectory || null);
const branches = useGitBranches(currentDirectory || null);
const isGitRepo = useIsGitRepo(currentDirectory || null);
const isLoadingStatus = useGitLoadingStatus(currentDirectory || null);
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
@@ -104,6 +108,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState<string | null>(null);
const changeEntries = React.useMemo(() => {
const files = status?.files ?? [];
@@ -157,6 +162,55 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
}
}, [currentDirectory, fetchBranches, fetchStatus, git, t]);
const localBranches = React.useMemo(
() => (branches?.all ?? []).filter((branch) => !branch.startsWith('remotes/')).sort(),
[branches],
);
const remoteBranches = React.useMemo(
() => (branches?.all ?? [])
.filter((branch) => branch.startsWith('remotes/'))
.map((branch) => branch.replace(/^remotes\//, ''))
.sort(),
[branches],
);
const performCheckout = React.useCallback(async (branch: string) => {
if (!currentDirectory) return;
const normalized = branch.replace(/^remotes\//, '');
try {
const result = await git.checkoutBranch(currentDirectory, normalized);
toast.success(t('gitView.toast.checkedOut', { name: result.branch || normalized }));
await refreshStatusAndBranches();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('gitView.toast.checkoutFailed', { name: normalized }));
}
}, [currentDirectory, git, refreshStatusAndBranches, t]);
const handleCheckoutBranch = React.useCallback((branch: string) => {
const normalized = branch.replace(/^remotes\//, '');
if ((status?.files?.length ?? 0) > 0) {
setPendingDirtySwitchBranch(normalized);
return;
}
void performCheckout(normalized);
}, [performCheckout, status?.files]);
const handleCreateBranch = React.useCallback(async (branch: string, remote?: GitRemote) => {
if (!currentDirectory) return;
try {
await git.createBranch(currentDirectory, branch, status?.current ?? 'HEAD');
await git.checkoutBranch(currentDirectory, branch);
if (remote) {
await git.gitPush(currentDirectory, { remote: remote.name, branch, options: ['--set-upstream'] });
}
await refreshStatusAndBranches();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('gitView.toast.createBranchFailed'));
throw error;
}
}, [currentDirectory, git, refreshStatusAndBranches, status?.current, t]);
const refreshRemotes = React.useCallback(async () => {
if (!currentDirectory) {
setRemotes([]);
@@ -542,9 +596,19 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
) : null}
<div className="min-w-0 flex-1 px-1">
<h2 className="typography-ui-label text-foreground">{t('mobile.nav.changes')}</h2>
<p className="truncate typography-micro text-muted-foreground">
{status?.current || currentDirectory}
</p>
<BranchSelector
currentBranch={status?.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branches?.branches}
currentBranchAhead={status?.ahead}
onCheckout={(branch) => void handleCheckoutBranch(branch)}
onCreate={handleCreateBranch}
remotes={effectiveRemotes}
disabled={isLoadingStatus}
directory={currentDirectory}
switchBlockedNotice={(status?.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null}
/>
</div>
<SyncActions
syncAction={syncAction}
@@ -594,6 +658,61 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
<MobileChangesState icon message={t('gitView.empty.cleanTitle')} description={t('mobile.changes.cleanDescription')} />
</div>
)}
<DirtyBranchSwitchDialog
open={pendingDirtySwitchBranch !== null}
onOpenChange={(open) => { if (!open) setPendingDirtySwitchBranch(null); }}
targetBranch={pendingDirtySwitchBranch ?? ''}
changedFileCount={status?.files?.length ?? 0}
onCommitAndSwitch={async (message, pushAfter) => {
const branch = pendingDirtySwitchBranch;
if (!branch || !currentDirectory) return;
const sourceBranch = status?.current ?? null;
await git.createGitCommit(currentDirectory, message, { addAll: true });
let pushedRemoteName: string | null = null;
if (pushAfter) {
const trackingRemoteName = status?.tracking?.split('/')[0];
const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0];
try {
if (!remote) throw new Error(t('mobile.changes.noRemote'));
await git.gitPush(currentDirectory, status?.tracking
? { remote: remote.name }
: { remote: remote.name, branch: sourceBranch ?? undefined, options: ['--set-upstream'] });
pushedRemoteName = remote.name;
} catch {
toast.error(t('gitView.dirtySwitch.pushFailed'));
await refreshStatusAndBranches();
setPendingDirtySwitchBranch(null);
return;
}
}
toast.success(sourceBranch
? pushedRemoteName
? t('gitView.toast.pushedToUpstream', { name: pushedRemoteName })
: t('gitView.dirtySwitch.committedNotPushed', { branch: sourceBranch })
: t('gitView.toast.commitCreated'));
await refreshStatusAndBranches();
setPendingDirtySwitchBranch(null);
await performCheckout(branch);
}}
onGenerateMessage={async () => {
if (!currentDirectory) return '';
const paths = (status?.files ?? []).map((file) => file.path).sort();
const { message } = await generateCommitMessage(currentDirectory, paths);
return message.subject?.trim() ?? '';
}}
onRevertAndSwitch={async () => {
const branch = pendingDirtySwitchBranch;
if (!branch || !currentDirectory) return;
await handleRevertAll((status?.files ?? []).map((file) => file.path));
const fresh = await git.getGitStatus(currentDirectory);
if (!fresh.isClean && (fresh.files?.length ?? 0) > 0) {
toast.error(t('gitView.dirtySwitch.revertIncomplete'));
return;
}
setPendingDirtySwitchBranch(null);
await performCheckout(branch);
}}
/>
</div>
);
};
@@ -11,6 +11,13 @@ import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useGitStore } from '@/stores/useGitStore';
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useQuotaStore } from '@/stores/useQuotaStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useUIStore } from '@/stores/useUIStore';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -68,6 +75,22 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
useGitHubPrStatusStore.getState().resetForRuntimeSwitch();
useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
// Linear and GitHub are authenticated on the instance, not in the browser.
// Left in place, the previous instance's login stayed visible and usable —
// its rail tab, its issue pickers, its work-status rows — against a runtime
// that has no such integration. `App` re-asks once the new instance answers.
useLinearAuthStore.getState().resetForRuntimeSwitch();
useGitHubAuthStore.getState().resetForRuntimeSwitch();
// Work-status readouts served from the instance: quotas, MCP servers, skills
// and agent memory. All were cached globally or by directory alone, so they
// reported the previous instance until something happened to refetch.
useQuotaStore.getState().resetForRuntimeSwitch();
useMcpStore.getState().resetForRuntimeSwitch();
useSkillsStore.getState().resetForRuntimeSwitch();
useAgentMemoryStore.getState().reset();
// The Linear team filter names a team in one workspace. Carried across, it
// filters the new instance's issue list down to nothing.
useUIStore.getState().applyLinearIssueListFiltersForRuntime();
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
resetStreamingState();
queueMicrotask(() => void syncDesktopSettings());
@@ -0,0 +1,9 @@
<svg width="24" height="24" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<title>exe.dev</title>
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="6.5" vector-effect="non-scaling-stroke" transform="translate(64 64) scale(.92 1.06) translate(-64 -64)">
<path d="M127.17 52.87c-.79-2.79-2.62-5.51-4.9-7.26-2.04-1.56-4.4-2.35-7.02-2.35-.79 0-1.62.08-2.45.22-2.72.49-4.31 1.28-6.47 2.65-.79.5-1.47.74-2.08.74-.36 0-.75-.08-1.18-.24-4.8-1.8-9.35-3.95-13.99-6.25.33-.21.64-.47.9-.79 1.23-1.5 1.45-4.14.74-5.94-1.14-2.94-4.75-4.27-7.68-4.37-3.35-.12-6.33 1.39-9.26 2.83l-.84.42a77.4 77.4 0 0 0-12.27-4.08c-3.5-.84-7.01-1.41-10.44-1.7-1.16-.1-6.48-.22-7.05-.2-9.23.32-17.75 3.22-24.14 8.24-2.48 1.95-4.43 4.29-5.86 6.11-1.61 2.06-2.9 4.1-3.88 6.1-.44-.04-.89-.08-1.34-.08-4.27 0-7.23 2.71-7.54 6.9l-.02.34c-.07 2.06.6 3.99 1.91 5.46A7.9 7.9 0 0 0 .7 63.54a8 8 0 0 0 1.86 6.11c1.57 1.86 3.71 2.91 6.06 3.12-.02 1.25.02 2.55.24 3.86 3.06 18.54 20.3 27.71 34.13 29.93 3.47.56 7.04.85 10.62.85 8.52 0 16.43-1.59 22.89-4.6 12.27-5.71 17.03-15.67 20.54-24.56.95-2.39 1.91-4.71 3.23-6.62.47-.67.73-.99.88-1.14.53-.09 1.15-.13 1.77-.13.98 0 1.94.12 2.62.32 2.54.75 6.02 1.78 9.52 1.78 1.34 0 2.61-.16 3.78-.45 6.57-1.72 8.08-9.79 8.39-12.23l.07-.54c.14-1.09.3-2.46.25-3.88-.04-.94-.16-1.75-.38-2.49"/>
<ellipse cx="28.36" cy="47.62" rx="4.88" ry="5.57"/>
<path d="M109.52 52.18c-.11-.48.15-.97.49-1.28.53-.48 1.42-1.02 2.04-1.33.71-.35 1.83-.32 2.48.09.55.35.62.55.63 1.21.03 1.53-1.25 1.56-2.43 1.86-.72.18-1.86.66-2.6.31-.28-.15-.49-.41-.61-.86M115.85 64.78c-.75 1.76-6.88.76-5.6-1.72l.36-.4c.43-.28 1.03-.26 1.57-.21 1.11.12 4.56.23 3.67 2.33M116.54 58.06c-.5 1.46-1.72 1.04-2.93.94-.73-.07-1.98-.01-2.55-.59-.22-.22-.34-.54-.28-.99.01-.12.1-.19.13-.3.16-.33.42-.61.77-.75.65-.27 1.67-.48 2.36-.56.78-.09 1.84.32 2.3.92.08.1.14.18.18.26.21.34.18.56.02 1.07"/>
<path d="M53.88 65.26c-2.9-2.26-7.22-2.84-10.77-2.22-.15 1.2-.22 2.52-.77 3.62-.58 1.14-1.82 1.26-2.44 2.2-1.01 1.54 1.69 5.58 2.63 6.8 1.44 1.84 2.92 2.66 5.26 2.65 1.03-.01 1.98.09 2.93-.28 1.09-.42 2.42-1.51 3.24-2.36 1.53-1.58 2.11-3.84 2.28-5.98.14-1.95-.83-3.24-2.36-4.43"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -2584,6 +2584,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
selectedDraftDirectory,
selectedDraftBranchLabel,
selectedDraftBranchIsKnown,
selectedDraftDirectoryHasUncommittedChanges,
projectRootBranchOption,
worktreeBranchOptions,
draftBranchItems,
@@ -2851,6 +2852,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
selectedDirectory={selectedDraftDirectory}
selectedBranchLabel={selectedDraftBranchLabel}
selectedBranchIsKnown={selectedDraftBranchIsKnown}
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
projectRootBranchOption={projectRootBranchOption}
worktreeBranchOptions={worktreeBranchOptions}
branchItems={draftBranchItems}
@@ -2865,6 +2867,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
<MobileDraftTargetTriggers
selectedProject={selectedDraftProject}
selectedBranchLabel={selectedDraftBranchLabel}
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
showBranchSelector={shouldShowDraftBranchSelector}
theme={currentTheme}
onOpenPicker={setMobileDraftPicker}
@@ -3276,6 +3279,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
selectedDirectory={selectedDraftDirectory}
selectedBranchLabel={selectedDraftBranchLabel}
selectedBranchIsKnown={selectedDraftBranchIsKnown}
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
projectRootBranchOption={projectRootBranchOption}
worktreeBranchOptions={worktreeBranchOptions}
branchItems={draftBranchItems}
@@ -169,7 +169,9 @@ and the send path reading the same grammar.
recorded before a queued write could resurrect it.
- `state/useDraftTarget.ts` — the draft can target a directory that does not
exist yet (a worktree being created). It must survive not appearing in the
branch list, or the selector snaps back to the project root mid-creation.
branch list, or the selector snaps back to the project root mid-creation. It
also owns the advisory dirty state for the selected directory, clearing it as
soon as the target changes so a warning never names a previous branch.
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
state and registers its application shortcuts locally. The selectors only
consume their shared prefix while the draft target UI is mounted.
@@ -25,6 +25,7 @@ import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
import { normalizePath } from '../attachments/filePaths';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
import { useI18n } from '@/lib/i18n';
import { getGitStatus } from '@/lib/gitApi';
/** How long a cached branch list is served before it is refreshed. */
const BRANCHES_SWR_TTL_MS = 30_000;
@@ -98,6 +99,7 @@ export function useDraftTarget(enabled: boolean) {
const hasDraftBranchList = Boolean(selectedDraftProjectBranches?.all);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false);
const [dirtyDraftDirectory, setDirtyDraftDirectory] = React.useState<string | null>(null);
React.useEffect(() => {
if (!enabled || !selectedDraftProjectPath || !runtimeGit || selectedDraftProjectIsGitRepo !== null) {
@@ -189,6 +191,35 @@ export function useDraftTarget(enabled: boolean) {
[newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath],
);
React.useEffect(() => {
if (
!enabled
|| !selectedDraftDirectory
|| selectedDraftProject?.kind === 'chat'
|| newSessionDraft?.pendingWorktreeRequestId
|| newSessionDraft?.bootstrapPendingDirectory
) {
setDirtyDraftDirectory(null);
return;
}
let cancelled = false;
setDirtyDraftDirectory(null);
getGitStatus(selectedDraftDirectory, { mode: 'light' })
.then((status) => {
if (!cancelled && (status.files?.length ?? 0) > 0) {
setDirtyDraftDirectory(selectedDraftDirectory);
}
})
.catch(() => {
if (!cancelled) setDirtyDraftDirectory(null);
});
return () => {
cancelled = true;
};
}, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftProject?.kind]);
const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => {
const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
return Boolean(
@@ -306,6 +337,7 @@ export function useDraftTarget(enabled: boolean) {
selectedDraftDirectory,
selectedDraftBranchLabel,
selectedDraftBranchIsKnown,
selectedDraftDirectoryHasUncommittedChanges: dirtyDraftDirectory === selectedDraftDirectory,
projectRootBranchOption,
worktreeBranchOptions,
draftBranchItems,
@@ -12,6 +12,7 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
import {
Select,
@@ -44,6 +45,7 @@ export interface DraftTargetProps {
selectedDirectory: string | null;
selectedBranchLabel: string | null;
selectedBranchIsKnown: boolean;
hasUncommittedChanges: boolean;
projectRootBranchOption: BranchOption | null;
worktreeBranchOptions: readonly BranchOption[];
branchItems: readonly BranchOption[];
@@ -92,14 +94,39 @@ function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme:
}
/** Desktop: inline project and branch selects. */
/** How long the dirty-directory tooltip announces itself before becoming hover-only. */
const DIRTY_TOOLTIP_FLASH_MS = 5000;
/**
* Opens the tooltip for a few seconds when the dirty state first appears, so
* the warning is seen without hovering, then hands control back to hover.
*/
function useDirtyFlashTooltip(hasUncommittedChanges: boolean) {
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
if (!hasUncommittedChanges) {
setOpen(false);
return;
}
setOpen(true);
const timer = window.setTimeout(() => setOpen(false), DIRTY_TOOLTIP_FLASH_MS);
return () => window.clearTimeout(timer);
}, [hasUncommittedChanges]);
return { open, onOpenChange: setOpen };
}
export function DraftTargetSelectors(props: DraftTargetProps) {
const { t } = useI18n();
const dirtyTooltip = useDirtyFlashTooltip(props.hasUncommittedChanges);
const {
projects,
selectedProject,
selectedDirectory,
selectedBranchLabel,
selectedBranchIsKnown,
hasUncommittedChanges,
projectRootBranchOption,
worktreeBranchOptions,
branchItems,
@@ -176,16 +203,32 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
onValueChange={handleDirectoryChange}
disableGlobalShortcuts
>
<SelectTrigger
ref={worktreeTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
<SelectValue>
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
<TooltipTrigger asChild>
<SelectTrigger
ref={worktreeTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
{hasUncommittedChanges ? (
<Icon
name="alert"
className="size-3.5 shrink-0 text-[var(--status-warning)]"
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
/>
) : null}
<SelectValue>
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
</TooltipTrigger>
{hasUncommittedChanges ? (
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
</TooltipContent>
) : null}
</Tooltip>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
{projectRootBranchOption ? (
<SelectGroup>
@@ -228,11 +271,12 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
/** Mobile: buttons that open the bottom sheets below. */
export function MobileDraftTargetTriggers(
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'theme'>
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'hasUncommittedChanges' | 'theme'>
& { onOpenPicker: (picker: 'project' | 'branch') => void },
) {
const { t } = useI18n();
const { selectedProject, selectedBranchLabel, showBranchSelector, theme, onOpenPicker } = props;
const { selectedProject, selectedBranchLabel, showBranchSelector, hasUncommittedChanges, theme, onOpenPicker } = props;
const dirtyTooltip = useDirtyFlashTooltip(hasUncommittedChanges);
return (
<div className="mb-1.5 flex min-w-0 items-center gap-x-2 px-0.5">
@@ -247,14 +291,30 @@ export function MobileDraftTargetTriggers(
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
{showBranchSelector ? (
<button
type="button"
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('branch')}
>
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('branch')}
>
{hasUncommittedChanges ? (
<Icon
name="alert"
className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]"
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
/>
) : null}
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
</TooltipTrigger>
{hasUncommittedChanges ? (
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
</TooltipContent>
) : null}
</Tooltip>
) : null}
</div>
);
@@ -347,6 +347,28 @@ the matching header dropdown:
discovered relative to the active project. It does not wrap the call in
`runBackgroundNetworkTask`: the store already gates its own fetch.
Usage waits for the instance to say it is initialised. Quota providers report
themselves as configured only once the instance can read their credentials,
which on a remote instance is not true when the UI mounts — a fetch fired at
mount gets "nothing configured" for every provider, and since each one then has
a result, nothing asks again until the three-minute refresh. That is why Usage
could stay missing from the panel until Settings -> Usage forced a fresh fetch.
`useQuotaStore.ensureLoadedForRuntime` owns both the readiness rule and the
once-per-instance bookkeeping, so every caller can ask on each connection
change.
### These readouts belong to the connected instance
Quotas, MCP status, skills, agent memory and the Linear/GitHub logins are all
served by whichever OpenChamber instance is connected, and each was cached
globally or by directory alone — which two instances can share. A switch left
the previous instance's answers on screen, and its Linear login usable against
a runtime that has no Linear. `apps/runtimeEndpointReset.ts` now drops all of
them, each store guarding its own in-flight requests with a generation so a
response for the previous instance cannot land in the new one. The MCP and
skills effects take `isConnected` as a dependency — not a gate — because
`directory` alone does not change when both instances hold the same path.
The panel now performs these itself, silently and through the
background-network gate, so it cannot compete with chat bootstrap traffic for
sockets. Usage additionally provides an explicit refresh action in its section
@@ -15,6 +15,7 @@ import { resolveProjectContextId } from '@/lib/projectContextApi';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useMobileAppActions } from '@/apps/mobileAppContext';
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
@@ -61,9 +62,15 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
// here: `loadSkills` already gates its own fetch, and wrapping it again
// would hold a second slot idle for the length of the first.
const loadSkills = useSkillsStore((state) => state.loadSkills);
// `isConnected` is a dependency, not a gate: skills are discovered on the
// connected instance and their caches are dropped when instances switch, so
// the count has to be asked for again once the new instance is up. Two
// instances can hold the same project path, which leaves `directory`
// unchanged across a switch.
const isConnected = useConfigStore((state) => state.isConnected);
React.useEffect(() => {
void loadSkills();
}, [directory, loadSkills]);
}, [directory, isConnected, loadSkills]);
/**
* What this session carries. Read from the server
@@ -2,6 +2,7 @@ import React from 'react';
import { useI18n } from '@/lib/i18n';
import { Switch } from '@/components/ui/switch';
import { useMcpStore } from '@/stores/useMcpStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { McpIcon } from '@/components/icons/McpIcon';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { toast } from 'sonner';
@@ -28,6 +29,7 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
const ensureMcpFresh = useMcpStore((state) => state.ensureFresh);
const connect = useMcpStore((state) => state.connect);
const disconnect = useMcpStore((state) => state.disconnect);
const isConnected = useConfigStore((state) => state.isConnected);
const [busyServer, setBusyServer] = React.useState<string | null>(null);
// The panel must not depend on the header dropdown having been mounted or
@@ -35,9 +37,12 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
// compete with chat bootstrap traffic for sockets. The section remounts on
// every session switch, so it only asks for a status that is missing or
// older than a minute; connect/disconnect/auth refresh on their own.
// `isConnected` is a dependency, not a gate: MCP status is cached by
// directory alone and dropped on an instance switch, and two instances can
// hold the same project path — so the switch itself has to trigger the ask.
React.useEffect(() => {
void runBackgroundNetworkTask(() => ensureMcpFresh({ directory, silent: true, maxAgeMs: MCP_STATUS_MAX_AGE_MS }));
}, [directory, ensureMcpFresh]);
}, [directory, ensureMcpFresh, isConnected]);
const mcpServers = React.useMemo(
() => Object.entries(mcpStatus ?? {}).sort(([left], [right]) => left.localeCompare(right)),
@@ -43,9 +43,10 @@ export const WorkStatusUsageSection: React.FC = () => {
const groups = useUsageProviderGroups();
const displayMode = useQuotaStore((state) => state.displayMode);
const isLoading = useQuotaStore((state) => state.isLoading);
const quotaResults = useQuotaStore((state) => state.results);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const fetchQuotas = useQuotaStore((state) => state.fetchQuotas);
const ensureQuotasLoadedForRuntime = useQuotaStore((state) => state.ensureLoadedForRuntime);
const isInitialized = useConfigStore((state) => state.isInitialized);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
@@ -54,17 +55,13 @@ export const WorkStatusUsageSection: React.FC = () => {
// `useQuotaAutoRefresh` only schedules an interval — it never performs the
// first fetch. That was owned by the header dropdown's open handler, so the
// panel stayed empty until the user opened it. Kick off the initial load for
// any enabled provider that has not reported yet, background-gated so it
// cannot compete with chat bootstrap traffic.
// panel stayed empty until the user opened it. `ensureLoadedForRuntime` owns
// the once-per-instance load and its readiness rule; asking again is a no-op,
// so this is safe to run on every connection change.
React.useEffect(() => {
if (isLoading || dropdownProviderIds.length === 0) return;
const missingProvider = dropdownProviderIds.some(
(providerId) => !quotaResults.some((result) => result.providerId === providerId),
);
if (!missingProvider) return;
void runBackgroundNetworkTask(() => fetchQuotas(dropdownProviderIds));
}, [dropdownProviderIds, fetchQuotas, isLoading, quotaResults]);
if (!isInitialized) return;
void runBackgroundNetworkTask(() => ensureQuotasLoadedForRuntime());
}, [ensureQuotasLoadedForRuntime, isInitialized]);
React.useEffect(() => {
if (groups.length === 0) return;
@@ -14,6 +14,7 @@ import { toast } from '@/components/ui';
import { isElectronShell, isDesktopShell } from '@/lib/desktop';
import { Icon } from "@/components/icon/Icon";
import { useUIStore } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useI18n } from '@/lib/i18n';
import {
desktopHostProbe,
@@ -37,6 +38,14 @@ import {
resolveCurrentDesktopHost,
runtimeKeyForDesktopHost,
} from '@/lib/desktopCurrentHost';
import {
getDesktopHostStatusSnapshot,
probeDesktopHosts,
setDesktopHostStatus,
pruneDesktopHostStatuses,
subscribeDesktopHostStatuses,
type DesktopHostStatus,
} from '@/lib/desktopHostStatus';
import { scheduleDesktopHostCandidateRefresh } from '@/lib/desktopRelayRestore';
import { adoptRelayTunnel } from '@/lib/relay/runtime-tunnel';
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
@@ -52,17 +61,7 @@ import {
const SSH_CONNECT_TIMEOUT_MS = 90_000;
const SSH_CONNECT_CANCELLED_ERROR = 'SSH connection cancelled';
type HostStatus = {
status: HostProbeResult['status'];
latencyMs: number;
/** Which transport the successful probe used (multi-transport hosts). */
via?: 'relay';
};
// Last known statuses survive the dropdown unmounting (it remounts on every
// open). Rows show the previous result immediately — refreshed quietly by the
// open-probe — instead of shouting "Unknown" at the user for a few seconds.
const lastKnownHostStatuses: Record<string, HostStatus> = {};
type HostStatus = DesktopHostStatus;
type HostDisplayStatus = HostProbeResult['status'] | 'checking' | null;
@@ -247,15 +246,17 @@ export function DesktopHostSwitcherDialog({
const { t } = useI18n();
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const isRuntimeConnected = useConfigStore((state) => state.isConnected);
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>(() => ({ ...lastKnownHostStatuses }));
React.useEffect(() => {
Object.assign(lastKnownHostStatuses, statusById);
}, [statusById]);
// Statuses live outside this component: startup warms them, and the dropdown
// remounts on every open — holding them here is what made each open start
// from nothing and show "Checking" on rows the app already knew about.
const statusSnapshot = React.useSyncExternalStore(subscribeDesktopHostStatuses, getDesktopHostStatusSnapshot, getDesktopHostStatusSnapshot);
const statusById = statusSnapshot.byHostId;
const isProbing = statusSnapshot.isProbing;
const [isLoading, setIsLoading] = React.useState(false);
const [isProbing, setIsProbing] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [switchingHostId, setSwitchingHostId] = React.useState<string | null>(null);
const [sshHostIds, setSshHostIds] = React.useState<Record<string, true>>({});
@@ -347,6 +348,10 @@ export function DesktopHostSwitcherDialog({
nextSshHostIds[instance.id] = true;
}
setConfigHosts(cfg.hosts || []);
// Config is the authoritative host list: drop statuses for instances the
// user removed. Doing this from a probe run instead would clear entries
// every time a run started before the config had finished loading.
pruneDesktopHostStatuses((cfg.hosts || []).map((host) => host.id));
setDefaultHostId(cfg.defaultHostId ?? null);
setSshHostIds(nextSshHostIds);
setSshStatusesById(sshStatusMap);
@@ -362,43 +367,7 @@ export function DesktopHostSwitcherDialog({
}, [t]);
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
if (!isDesktopShell()) return;
setIsProbing(true);
try {
const localClientToken = await getLocalClientToken();
const results = await Promise.all(
hosts.map(async (h) => {
const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || '');
const probeRelayLeg = async (): Promise<HostStatus> => {
const res = await probeRelayDesktopHost(h.relay!, { clientToken, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
return { status: res.status, latencyMs: res.latencyMs, ...(res.status === 'ok' ? { via: 'relay' as const } : {}) };
};
// Relay-only host: no HTTP address — probe through the E2EE tunnel.
if (h.relay && !h.apiUrl) {
return [h.id, await probeRelayLeg()] as const;
}
const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(h) : h.url);
if (!url) {
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
}
const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
// Multi-transport host away from its network: the direct leg fails
// but the relay may still reach it.
if (isBlockedHostStatus(res.status) && h.relay) {
const relayStatus = await probeRelayLeg();
if (relayStatus.status === 'ok') return [h.id, relayStatus] as const;
}
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
})
);
const next: Record<string, HostStatus> = {};
for (const [id, val] of results) {
next[id] = val;
}
setStatusById(next);
} finally {
setIsProbing(false);
}
await probeDesktopHosts(hosts);
}, []);
React.useEffect(() => {
@@ -514,7 +483,7 @@ export function DesktopHostSwitcherDialog({
relayProbeTunnel = 'tunnel' in probe ? probe.tunnel : undefined;
}
}
setStatusById((prev) => ({ ...prev, [host.id]: finalStatus }));
setDesktopHostStatus(host.id, finalStatus);
if (!transport) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
@@ -620,10 +589,7 @@ export function DesktopHostSwitcherDialog({
if (host.id !== LOCAL_HOST_ID && isDesktopShell()) {
setSwitchingHostId(host.id);
const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
}));
setDesktopHostStatus(host.id, { status: probe.status, latencyMs: probe.latencyMs });
if (isBlockedHostStatus(probe.status)) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
@@ -863,12 +829,17 @@ export function DesktopHostSwitcherDialog({
const status = statusById[host.id] || null;
const sshStatus = sshStatusesById[host.id] || null;
// While a probe runs, keep showing the last known result (quiet
// refresh); only fall back to "Checking" when there has never
// been one. "Unknown" is never shown — an unprobed host is by
// definition being checked.
// refresh — the header's refresh icon is the spinner); only fall
// back to "Checking" when there has never been one. "Unknown" is
// never shown — an unprobed host is by definition being checked.
//
// The instance the app is connected to never says "Checking":
// the live connection already answers the question a probe would
// ask, and reporting otherwise reads as the app not knowing where
// it is. A real probe result still wins — it carries the ping.
const statusKind: HostDisplayStatus = isSsh
? sshPhaseToHostStatus(sshStatus?.phase)
: (status?.status ?? 'checking');
: (status?.status ?? (isActive && isRuntimeConnected ? 'ok' : 'checking'));
const isEditing = editingId === host.id;
const effectiveUrl = isLocal ? localOrigin : (normalizeHostUrl(host.url) || host.url);
const displayLabel = host.id === LOCAL_HOST_ID
+2 -27
View File
@@ -124,7 +124,6 @@ const HeaderIconActionButton = React.memo(function HeaderIconActionButton({
type DesktopServicesMenuProps = {
isDesktopApp: boolean;
currentInstanceLabel: string;
compactCurrentInstanceLabel: string;
currentInstanceIsLocal: boolean;
isDesktopServicesOpen: boolean;
setIsDesktopServicesOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -139,7 +138,6 @@ type DesktopServicesMenuProps = {
const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
isDesktopApp,
currentInstanceLabel,
compactCurrentInstanceLabel,
currentInstanceIsLocal,
isDesktopServicesOpen,
setIsDesktopServicesOpen,
@@ -171,12 +169,12 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
: t('header.services.open')}
className={cn(
DESKTOP_HEADER_ICON_BUTTON_CLASS,
isDesktopApp ? 'w-auto max-w-[14rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8'
isDesktopApp ? 'w-auto max-w-[20rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8'
)}
>
<Icon name="server" className="h-[18px] w-[18px]" />
{isDesktopApp ? (
<span className="truncate typography-ui-label font-medium text-foreground">{compactCurrentInstanceLabel}</span>
<span className="truncate typography-ui-label font-medium text-foreground">{currentInstanceLabel}</span>
) : null}
</button>
</DropdownMenuTrigger>
@@ -251,27 +249,6 @@ const isSameContextUsage = (
&& (a.lastMessageId ?? '') === (b.lastMessageId ?? '');
};
const formatCompactHeaderLabel = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
const words = trimmed.split(/\s+/).filter(Boolean);
if (words.length >= 2) {
const first = words[0];
const second = words[1].slice(0, 3);
const shortTwoWord = `${first} ${second}`.trim();
if (words.length > 2 || shortTwoWord.length < trimmed.length) {
return `${shortTwoWord}...`;
}
return shortTwoWord;
}
return trimmed.length > 12 ? `${trimmed.slice(0, 9).trimEnd()}...` : trimmed;
};
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
@@ -447,7 +424,6 @@ export const Header: React.FC = () => {
const [remoteUpdateInfo, setRemoteUpdateInfo] = React.useState<UpdateInfo | null>(null);
const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false);
const [remoteUpdateError, setRemoteUpdateError] = React.useState<string | null>(null);
const compactCurrentInstanceLabel = React.useMemo(() => formatCompactHeaderLabel(currentInstanceLabel), [currentInstanceLabel]);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
// While the work-status panel is on screen it already reports the project,
// the branch and the context fill — three paces away in the same window.
@@ -1293,7 +1269,6 @@ export const Header: React.FC = () => {
<DesktopServicesMenu
isDesktopApp={isDesktopApp}
currentInstanceLabel={currentInstanceLabel}
compactCurrentInstanceLabel={compactCurrentInstanceLabel}
currentInstanceIsLocal={currentInstanceIsLocal}
isDesktopServicesOpen={isDesktopServicesOpen}
setIsDesktopServicesOpen={setIsDesktopServicesOpen}
@@ -5,21 +5,23 @@ import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
type ProviderId = 'ollama-cloud' | 'cursor';
type ProviderId = 'exe-dev' | 'ollama-cloud' | 'cursor';
type Status = { configured: boolean; secretMasked?: string };
type CredentialPayload = { usageToken?: string; cookie?: string; accessToken?: string; refreshToken?: string };
const EXE_DEV_TOKEN_COMMAND = `ssh exe.dev "ssh-key generate-api-key --label=openchamber --exp=30d --cmds='billing credits usage'"`;
export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: string }> = ({ providerId, providerName }) => {
const { t } = useI18n();
const [status, setStatus] = React.useState<Status | null>(null);
const [values, setValues] = React.useState<Record<string, string>>({});
const [values, setValues] = React.useState<CredentialPayload>({});
const [busy, setBusy] = React.useState(false);
const route = `/api/quota/credentials/${providerId}`;
React.useEffect(() => { void runtimeFetch(route).then(async (response) => {
if (!response.ok) throw new Error();
const next = await response.json() as Status;
const next: Status = await response.json();
setStatus(next); setValues({});
}).catch(() => setStatus({ configured: false })); }, [route]);
const request = async (path: string, method: string, body?: object) => {
const request = async (path: string, method: string, body?: CredentialPayload) => {
setBusy(true);
try {
const response = await runtimeFetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
@@ -31,11 +33,16 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName:
} catch (error) { toast.error(error instanceof Error && error.message ? error.message : t('settings.providers.page.openCodeGo.saveFailed')); }
finally { setBusy(false); }
};
const field = (name: string, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type="password" autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
const field = (name: keyof CredentialPayload, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type="password" autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
return <div data-settings-item={`usage.${providerId}-credentials`} className="mb-8">
<div className="mb-1 px-1"><h3 className="typography-ui-header font-medium text-foreground">{providerName}</h3></div>
<section className="space-y-3 px-2 pb-2 pt-0">
{providerId === 'exe-dev' && <div className="space-y-1.5">
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.quotaCredentials.exeDevTokenInstructions')}</p>
<code className="typography-code block whitespace-pre-wrap break-all rounded bg-muted/50 px-2 py-1.5 text-xs text-foreground">{EXE_DEV_TOKEN_COMMAND}</code>
</div>}
{providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'session=...')}
{providerId === 'exe-dev' && field('usageToken', t('settings.providers.page.quotaCredentials.usageToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
{providerId === 'cursor' && field('accessToken', t('settings.providers.page.quotaCredentials.accessToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
{providerId === 'cursor' && field('refreshToken', t('settings.providers.page.quotaCredentials.refreshToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
<div className="flex flex-wrap gap-2">
@@ -80,7 +80,7 @@ export const UsagePage: React.FC = () => {
? selectedResult.error
: null;
const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false;
const hasCredentialsForm = selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
const hasCredentialsForm = selectedProviderId === 'exe-dev' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
const handleDropdownToggle = React.useCallback((enabled: boolean) => {
if (!selectedProviderId) {
return;
@@ -204,7 +204,7 @@ export const UsagePage: React.FC = () => {
</div>
)}
{(selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
{(selectedProviderId === 'exe-dev' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
<QuotaCredentials providerId={selectedProviderId} providerName={providerName} />
)}
@@ -285,7 +285,14 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
className={cn(
'flex min-w-0 flex-1 items-center gap-1.5 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-[padding]',
// Reserve hover space for the absolute action buttons,
// matching the collapse-toggle branch below.
isRepo && !hideDirectoryControls
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
)}
aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })}
>
<ProjectHeaderIdentity id={id} projectLabel={projectLabel} projectIcon={projectIcon} projectColor={projectColor} projectIconImage={projectIconImage} projectIconBackground={projectIconBackground} />
@@ -2,6 +2,8 @@ import React from 'react';
import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web';
import { cn } from '@/lib/utils';
import { loadMonoFont } from '@/lib/fontLoader';
import type { MonoFontOption } from '@/lib/fontOptions';
import type { TerminalTheme } from '@/lib/terminalTheme';
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
import {
@@ -27,20 +29,24 @@ const loadGhostty = (): Promise<GhosttyRuntime> =>
ghostty: await module.Ghostty.load(),
}));
// The web entry defers its ~2 MB Nerd Font download until a terminal actually
// mounts (see the `__openchamberEnsureNerdFonts` hook in index.html). Wait for
// it with a short bound so a cached font is in place before the glyph atlas is
// built, while a cold CDN fetch never blocks the terminal from opening; the
// runtimes without the hook (VS Code, mobile) resolve immediately.
const NERD_FONT_WAIT_MS = 2000;
const ensureNerdFonts = (): Promise<void> => {
if (typeof window === 'undefined') return Promise.resolve();
const loader = (window as typeof window & { __openchamberEnsureNerdFonts?: () => Promise<void> }).__openchamberEnsureNerdFonts;
if (typeof loader !== 'function') return Promise.resolve();
return Promise.race([
Promise.resolve(loader()).catch(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, NERD_FONT_WAIT_MS)),
]).then(() => undefined);
// Wait briefly for both the selected mono font and the web entry's deferred
// Nerd Fonts before Ghostty measures glyphs. A cold CDN fetch must not block
// opening the terminal, so the renderer starts after the bound and is rebuilt
// once the fonts arrive. Runtimes without the Nerd Font hook resolve it at once.
const TERMINAL_FONT_WAIT_MS = 2000;
const loadNerdFonts = (): Promise<void> =>
Promise.resolve(window.__openchamberEnsureNerdFonts?.()).catch(() => undefined);
const waitForTerminalFonts = (font: MonoFontOption) => {
const loaded = Promise.all([loadMonoFont(font), loadNerdFonts()]).then(() => undefined);
const loadedBeforeTimeout = new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => resolve(false), TERMINAL_FONT_WAIT_MS);
void loaded.then(() => {
clearTimeout(timeout);
resolve(true);
});
});
return { loaded, loadedBeforeTimeout };
};
type TerminalSize = { cols: number; rows: number };
@@ -91,6 +97,7 @@ type Props = {
onInput: (data: string) => void;
onResize: (cols: number, rows: number) => void;
theme: TerminalTheme;
monoFont: MonoFontOption;
fontFamily: string;
fontSize: number;
className?: string;
@@ -100,7 +107,7 @@ type Props = {
};
const TerminalViewport = React.forwardRef<TerminalController, Props>(({
sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className,
sessionKey, chunks, onInput, onResize, theme, monoFont, fontFamily, fontSize, className,
enableTouchScroll = false, autoFocus = true, isVisible = true,
}, ref) => {
const containerRef = React.useRef<HTMLDivElement>(null);
@@ -236,7 +243,8 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
window.addEventListener('focus', handleWindowFocus);
window.addEventListener('blur', handleWindowBlur);
Promise.all([loadGhostty(), ensureNerdFonts()]).then(([{ module, ghostty }]) => {
const fonts = waitForTerminalFonts(monoFont);
Promise.all([loadGhostty(), fonts.loadedBeforeTimeout]).then(([{ module, ghostty }, fontsLoaded]) => {
if (disposed) return;
terminal = new module.Terminal({
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
@@ -264,6 +272,13 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
const safeReset = safeResetRef.current;
if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`);
fitFrame = requestAnimationFrame(fit);
if (!fontsLoaded) {
void fonts.loaded.then(() => {
if (!disposed && terminalRef.current === terminal) {
setRendererGeneration((value) => value + 1);
}
});
}
});
return () => {
@@ -301,7 +316,7 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
writeEpochRef.current += 1;
rendererReadyRef.current = false;
};
}, [fit, fontFamily, fontSize, rendererGeneration, theme]);
}, [fit, fontFamily, fontSize, monoFont, rendererGeneration, theme]);
React.useEffect(() => {
const terminal = terminalRef.current;
+7 -2
View File
@@ -256,6 +256,7 @@ type ContentProps = React.ComponentProps<typeof BaseTooltip.Popup> & {
sideOffset?: number;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
showArrow?: boolean;
};
function TooltipContent({
@@ -265,6 +266,7 @@ function TooltipContent({
align,
children,
style,
showArrow = false,
...props
}: ContentProps) {
return (
@@ -277,14 +279,17 @@ function TooltipContent({
// data-instant is set when moving between grouped tooltips
// (shared TooltipProvider): reposition without replaying the
// full exit/enter animation.
"oc-glass-tooltip text-[var(--surface-elevated-foreground)] border border-border/60 transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 data-[instant]:transition-none data-[instant]:duration-0 z-50 w-fit origin-[var(--transform-origin)] rounded-xl px-3 py-1.5 typography-meta text-balance overflow-hidden",
"oc-glass-tooltip text-[var(--surface-elevated-foreground)] border border-border/60 transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 data-[instant]:transition-none data-[instant]:duration-0 z-50 w-fit origin-[var(--transform-origin)] rounded-xl px-3 py-1.5 typography-meta text-balance",
showArrow ? "overflow-visible" : "overflow-hidden",
className
)}
style={{ ...style }}
{...props}
>
{children}
<BaseTooltip.Arrow className="fill-[var(--surface-elevated)] z-50 size-2" />
{showArrow ? (
<BaseTooltip.Arrow className="relative z-50 block h-1.5 w-3 overflow-clip data-[side=bottom]:top-[-6px] data-[side=left]:right-[-9px] data-[side=left]:rotate-90 data-[side=right]:left-[-9px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-6px] data-[side=top]:rotate-180 before:absolute before:bottom-0 before:left-1/2 before:block before:h-[calc(6px*sqrt(2))] before:w-[calc(6px*sqrt(2))] before:border before:border-border/60 before:bg-[var(--surface-elevated)] before:content-[''] before:[transform:translate(-50%,50%)_rotate(45deg)]" />
) : null}
</BaseTooltip.Popup>
</BaseTooltip.Positioner>
</BaseTooltip.Portal>
+97 -1
View File
@@ -58,6 +58,7 @@ import { GitEmptyState } from './git/GitEmptyState';
import { HistorySection } from './git/HistorySection';
import { ConflictDialog } from './git/ConflictDialog';
import { StashDialog } from './git/StashDialog';
import { DirtyBranchSwitchDialog } from './git/DirtyBranchSwitchDialog';
import { InProgressOperationBanner } from './git/InProgressOperationBanner';
import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection';
import { deriveBaseBranch } from './git/baseBranch';
@@ -706,6 +707,8 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}
}, [conflictStorageKey, gitDirectory]);
const [stashDialogOpen, setStashDialogOpen] = React.useState(false);
// Branch a dirty-tree switch is waiting on; null when no switch is blocked.
const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState<string | null>(null);
const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge');
const [stashDialogBranch, setStashDialogBranch] = React.useState('');
@@ -1371,6 +1374,21 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
return;
}
// A checkout over uncommitted changes can carry them onto the target
// branch, conflict, or silently rewrite what the user was editing. The
// switch is blocked until the working tree is resolved: commit, or
// explicitly revert (DirtyBranchSwitchDialog).
if ((status?.files?.length ?? 0) > 0) {
setPendingDirtySwitchBranch(normalized);
return;
}
await performCheckout(normalized);
};
const performCheckout = async (branch: string) => {
if (!gitDirectory) return;
const normalized = branch;
try {
// Picking a remote-tracking branch checks out the local branch that
// tracks it, so report the branch the repository actually landed on.
@@ -2369,7 +2387,8 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
return (
<div className={cn('flex h-full flex-col overflow-hidden')}>
<GitHeader
<GitHeader
directory={gitDirectory ?? ''}
status={status}
localBranches={localBranches}
remoteBranches={remoteBranches}
@@ -2670,6 +2689,83 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
/>
)}
<DirtyBranchSwitchDialog
open={pendingDirtySwitchBranch !== null}
onOpenChange={(open) => { if (!open) setPendingDirtySwitchBranch(null); }}
targetBranch={pendingDirtySwitchBranch ?? ''}
changedFileCount={status?.files?.length ?? 0}
onCommitAndSwitch={async (message, pushAfter) => {
const branch = pendingDirtySwitchBranch;
if (!branch || !gitDirectory) return;
const sourceBranch = status?.current ?? null;
await git.createGitCommit(gitDirectory, message, { addAll: true });
bumpIndexRevision(gitDirectory);
let pushedRemoteName: string | null = null;
if (pushAfter) {
const trackingRemoteName = status?.tracking?.split('/')[0];
const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0];
try {
if (!remote) throw new Error(t('mobile.changes.noRemote'));
await git.gitPush(gitDirectory, status?.tracking
? { remote: remote.name }
: { remote: remote.name, branch: sourceBranch ?? undefined, options: ['--set-upstream'] });
pushedRemoteName = remote.name;
} catch (error) {
// The commit stands, so nothing is lost — but the switch is
// cancelled: the user must see the failed push on the branch it
// belongs to instead of discovering it later from elsewhere.
console.error('Push after commit failed:', error);
toast.error(t('gitView.dirtySwitch.pushFailed'));
await refreshStatusAndBranches();
await refreshLog();
setPendingDirtySwitchBranch(null);
return;
}
}
// Without a push the commit stays local on the branch being left;
// after the switch nothing on screen would say so, so the toast must.
toast.success(pushedRemoteName
? t('gitView.toast.pushedToUpstream', { name: pushedRemoteName })
: sourceBranch
? t('gitView.dirtySwitch.committedNotPushed', { branch: sourceBranch })
: t('gitView.toast.commitCreated'));
await refreshStatusAndBranches();
await refreshLog();
setPendingDirtySwitchBranch(null);
await performCheckout(branch);
}}
onGenerateMessage={async () => {
if (!gitDirectory) return '';
const paths = (status?.files ?? []).map((file) => file.path).sort();
const { message } = await generateSessionCommitMessage(gitDirectory, paths);
const subject = message.subject?.trim() ?? '';
// Same gitmoji decoration as the commit panel's Generate button.
if (subject && settingsGitmojiEnabled && gitmojiEmojis.length > 0) {
const match = matchGitmojiFromSubject(subject, gitmojiEmojis);
if (match && !subject.startsWith(match.code) && !subject.startsWith(match.emoji)) {
return `${match.code} ${subject}`;
}
}
return subject;
}}
onRevertAndSwitch={async () => {
const branch = pendingDirtySwitchBranch;
if (!branch || !gitDirectory) return;
const paths = (status?.files ?? []).map((file) => file.path);
await handleRevertPaths(paths, true, 'all');
// The revert reports its own partial failures; the checkout happens
// only once the tree is verifiably clean, so a half-reverted tree is
// never switched over.
const fresh = await git.getGitStatus(gitDirectory);
if (!fresh.isClean && (fresh.files?.length ?? 0) > 0) {
toast.error(t('gitView.dirtySwitch.revertIncomplete'));
return;
}
setPendingDirtySwitchBranch(null);
await performCheckout(branch);
}}
/>
<StashDialog
open={stashDialogOpen}
onOpenChange={setStashDialogOpen}
@@ -251,6 +251,14 @@ export const LinearIssuesView: React.FC = () => {
const setListPriority = useUIStore((state) => state.setLinearIssueListPriority);
const resetListFilters = useUIStore((state) => state.resetLinearIssueListFilters);
const setLinearIssueFocus = useUIStore((state) => state.setLinearIssueFocus);
const applyLinearFiltersForRuntime = useUIStore((state) => state.applyLinearIssueListFiltersForRuntime);
// The team filter is stored per instance, and rehydration can run before the
// runtime endpoint is known. Reading it here means the view always opens on
// the filter belonging to the instance it is about to query.
React.useEffect(() => {
applyLinearFiltersForRuntime();
}, [applyLinearFiltersForRuntime]);
const [query, setQuery] = React.useState('');
const [searchOpen, setSearchOpen] = React.useState(false);
@@ -1140,6 +1140,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
onInput={handleViewportInput}
onResize={handleViewportResize}
theme={xtermTheme}
monoFont={monoFont}
fontFamily={resolvedFontStack}
fontSize={terminalFontSize}
enableTouchScroll={useTouchTerminalInput}
@@ -100,4 +100,13 @@ describe('terminal viewport remount guard', () => {
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
});
test('rebuilds the canvas renderer when terminal fonts finish loading after the startup bound', () => {
expect(terminalViewportSource).toContain('loadMonoFont(font)');
expect(terminalViewportSource).toContain('Promise.all([loadMonoFont(font), loadNerdFonts()])');
expect(terminalViewportSource).toContain('Promise.all([loadGhostty(), fonts.loadedBeforeTimeout])');
expect(terminalViewportSource).toContain('if (!fontsLoaded)');
expect(terminalViewportSource).toContain('void fonts.loaded.then(() => {');
expect(terminalViewportSource).toContain('setRendererGeneration((value) => value + 1)');
});
});
@@ -16,9 +16,13 @@ import {
} from '@/components/ui/command';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import type { GitRemote } from '@/lib/api/types';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { useI18n } from '@/lib/i18n';
import { useDeviceInfo } from '@/lib/device';
import { getGitUnpushedBranchCounts } from '@/lib/gitApi';
import { getRecentBranches, rememberRecentBranch } from './recentBranches';
interface BranchInfo {
ahead?: number;
@@ -30,10 +34,18 @@ interface BranchSelectorProps {
localBranches: string[];
remoteBranches: string[];
branchInfo: Record<string, BranchInfo> | undefined;
currentBranchAhead?: number;
onCheckout: (branch: string) => void;
onCreate: (name: string, remote?: GitRemote) => Promise<void>;
remotes?: GitRemote[];
disabled?: boolean;
directory: string;
/**
* Shown above the branch list while the working tree has uncommitted
* changes: selecting a branch will not switch directly but opens the
* commit-or-revert resolution instead.
*/
switchBlockedNotice?: string | null;
}
const sanitizeBranchNameInput = (value: string): string => {
@@ -54,18 +66,24 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
localBranches,
remoteBranches,
branchInfo,
currentBranchAhead = 0,
onCheckout,
onCreate,
remotes = [],
disabled = false,
directory,
switchBlockedNotice = null,
}) => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
const [isOpen, setIsOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const [showCreate, setShowCreate] = React.useState(false);
const [showRemoteSelect, setShowRemoteSelect] = React.useState(false);
const [newBranchName, setNewBranchName] = React.useState('');
const [isCreating, setIsCreating] = React.useState(false);
const [recentBranches, setRecentBranches] = React.useState<string[]>(() => getRecentBranches(directory));
const [unpushedCounts, setUnpushedCounts] = React.useState<Record<string, number>>({});
const createInputRef = React.useRef<HTMLInputElement>(null);
const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
@@ -94,6 +112,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
setIsOpen(false);
return;
}
setRecentBranches(rememberRecentBranch(directory, branch));
onCheckout(branch);
setIsOpen(false);
setSearch('');
@@ -158,6 +177,109 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
}
}, [isOpen]);
React.useEffect(() => {
if (!directory) return;
setRecentBranches(currentBranch
? rememberRecentBranch(directory, currentBranch)
: getRecentBranches(directory));
}, [currentBranch, directory]);
React.useEffect(() => {
if (!isOpen) return;
const branches = recentBranches.filter((branch) => localBranches.includes(branch)).slice(0, 5);
if (branches.length === 0) return setUnpushedCounts({});
let cancelled = false;
getGitUnpushedBranchCounts(directory, branches)
.then(({ counts }) => { if (!cancelled) setUnpushedCounts(counts); })
.catch(() => { if (!cancelled) setUnpushedCounts({}); });
return () => { cancelled = true; };
}, [directory, isOpen, localBranches, recentBranches]);
if (isMobile) {
const recentLocalBranches = recentBranches.filter((branch) => localBranches.includes(branch));
const renderBranch = (branch: string, remote = false) => {
const ahead = unpushedCounts[branch] ?? (branch === currentBranch ? currentBranchAhead : 0);
const aheadLabel = ahead === 1
? t('gitView.branch.unpushedSingle')
: t('gitView.branch.unpushedPlural', { count: ahead });
return (
<button
key={`${remote ? 'remote' : 'local'}-${branch}`}
type="button"
onClick={() => handleCheckout(branch)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-2.5 text-left typography-ui-label hover:bg-interactive-hover"
>
<span className="min-w-0 flex-1 truncate">{branch}</span>
{ahead > 0 ? (
<span
className="inline-flex shrink-0 items-center gap-1 typography-micro text-muted-foreground"
title={aheadLabel}
aria-label={aheadLabel}
>
<Icon name="arrow-up" className="size-3" aria-hidden="true" />
<span aria-hidden="true">{ahead}</span>
</span>
) : null}
{currentBranch === branch ? <Icon name="check" className="size-4 shrink-0 text-primary" /> : null}
</button>
);
};
return (
<>
<Button
variant="ghost"
size="sm"
className="h-8 min-w-0 max-w-full justify-start gap-1.5 px-2 py-1"
disabled={disabled}
onClick={() => setIsOpen(true)}
>
<Icon name="git-branch" className="size-4 text-primary" />
<span className="min-w-0 truncate font-medium text-left">
{currentBranch || t('gitView.branch.detachedHead')}
</span>
<Icon name="arrow-down-s" className="size-4 opacity-60" />
</Button>
<MobileOverlayPanel
open={isOpen}
title={t('gitView.branch.currentBranchTooltip')}
onClose={() => setIsOpen(false)}
>
<div className="flex flex-col gap-2 px-3 pb-4 pt-1">
<input
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('gitView.branch.searchPlaceholder')}
className="h-9 w-full rounded-lg border border-border bg-transparent px-3 typography-meta outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary"
/>
{switchBlockedNotice ? (
<div className="flex items-start gap-2 px-2 py-1">
<Icon name="alert" className="mt-0.5 size-3.5 shrink-0 text-[var(--status-warning)]" aria-hidden="true" />
<span className="typography-micro text-muted-foreground">{switchBlockedNotice}</span>
</div>
) : null}
{recentLocalBranches.length > 0 ? (
<section>
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.recentBranches')}</p>
{recentLocalBranches.map((branch) => renderBranch(branch))}
</section>
) : null}
<section>
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.localBranches')}</p>
{filteredLocal.map((branch) => renderBranch(branch))}
</section>
<section>
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.remoteBranches')}</p>
{filteredRemote.map((branch) => renderBranch(branch, true))}
</section>
</div>
</MobileOverlayPanel>
</>
);
}
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
@@ -192,6 +314,12 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
onValueChange={setSearch}
onKeyDown={stopDropdownTypeahead}
/>
{switchBlockedNotice ? (
<div className="flex items-start gap-2 border-b border-border/60 px-3 py-2">
<Icon name="alert" className="mt-0.5 size-3.5 shrink-0 text-[var(--status-warning)]" aria-hidden="true" />
<span className="typography-micro text-muted-foreground">{switchBlockedNotice}</span>
</div>
) : null}
<CommandList
scrollbarClassName="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
disableHorizontal
@@ -288,6 +416,38 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
<CommandSeparator />
{recentBranches.filter((branch) => localBranches.includes(branch)).length > 0 ? (
<>
<CommandGroup heading={t('gitView.branch.recentBranches')}>
{recentBranches.filter((branch) => localBranches.includes(branch)).map((branch) => (
<CommandItem key={`recent-${branch}`} onSelect={() => handleCheckout(branch)}>
<span className="flex flex-1 items-center gap-2 min-w-0">
<span className="typography-ui-label text-foreground truncate">{branch}</span>
{(() => {
const ahead = unpushedCounts[branch] ?? (branch === currentBranch ? currentBranchAhead : 0);
const aheadLabel = ahead === 1
? t('gitView.branch.unpushedSingle')
: t('gitView.branch.unpushedPlural', { count: ahead });
return ahead > 0 ? (
<span
className="inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 typography-micro text-muted-foreground"
title={aheadLabel}
aria-label={aheadLabel}
>
<Icon name="arrow-up" className="size-3" aria-hidden="true" />
<span aria-hidden="true">{ahead}</span>
</span>
) : null;
})()}
</span>
{currentBranch === branch ? <span className="typography-micro text-primary">{t('gitView.branch.currentBadge')}</span> : null}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
) : null}
<CommandGroup heading={t('gitView.branch.localBranches')}>
{filteredLocal.map((branch) => (
<CommandItem
@@ -0,0 +1,197 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
interface DirtyBranchSwitchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
targetBranch: string;
changedFileCount: number;
/**
* Commit every uncommitted change with this message pushing the commit
* first when the user opted in then perform the checkout.
*/
onCommitAndSwitch: (message: string, pushAfter: boolean) => Promise<void>;
/** Produce an AI commit message for the current changes, same as the commit panel. */
onGenerateMessage: () => Promise<string>;
/** Revert every uncommitted change, then perform the checkout. */
onRevertAndSwitch: () => Promise<void>;
}
/**
* Switching branches with uncommitted changes is blocked so a checkout can
* never silently carry, conflict with, or drop the user's work. The user
* resolves the working tree with one explicit choice: commit and switch
* (message written, generated on demand, or generated automatically when the
* field is left empty same pipeline as the commit panel), or revert and
* switch. Cancel leaves everything untouched for a fully manual flow.
*/
export const DirtyBranchSwitchDialog: React.FC<DirtyBranchSwitchDialogProps> = ({
open,
onOpenChange,
targetBranch,
changedFileCount,
onCommitAndSwitch,
onGenerateMessage,
onRevertAndSwitch,
}) => {
const { t } = useI18n();
const [commitMessage, setCommitMessage] = React.useState('');
const [pendingAction, setPendingAction] = React.useState<'generate' | 'commit' | 'revert' | null>(null);
const [pushAfter, setPushAfter] = React.useState(false);
const isProcessing = pendingAction !== null;
React.useEffect(() => {
if (!open) {
setCommitMessage('');
setPushAfter(false);
}
}, [open]);
const handleGenerate = async () => {
setPendingAction('generate');
try {
const generated = await onGenerateMessage();
if (generated) setCommitMessage(generated);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed'));
} finally {
setPendingAction(null);
}
};
// An empty field is not an obstacle: the message is generated on the spot,
// through the same pipeline as the commit panel, and the commit proceeds.
const handleCommitAndSwitch = async () => {
setPendingAction('commit');
try {
let message = commitMessage.trim();
if (!message) {
message = (await onGenerateMessage()).trim();
if (!message) {
toast.error(t('gitView.toast.enterCommitMessage'));
return;
}
setCommitMessage(message);
}
await onCommitAndSwitch(message, pushAfter);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed'));
} finally {
setPendingAction(null);
}
};
const handleRevertAndSwitch = async () => {
setPendingAction('revert');
try {
await onRevertAndSwitch();
} catch (err) {
toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed'));
} finally {
setPendingAction(null);
}
};
return (
<Dialog open={open} onOpenChange={(next) => { if (!isProcessing) onOpenChange(next); }}>
<DialogContent className="max-w-md w-[calc(100vw-2rem)]">
<div className="flex flex-col gap-4">
<DialogHeader>
<div className="flex items-center gap-2">
<Icon name="alert" className="size-5 shrink-0 text-[var(--status-warning)]" />
<DialogTitle>{t('gitView.dirtySwitch.title')}</DialogTitle>
</div>
<DialogDescription>
{changedFileCount === 1
? t('gitView.dirtySwitch.descriptionSingle', { branch: targetBranch })
: t('gitView.dirtySwitch.descriptionPlural', { branch: targetBranch, count: changedFileCount })}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 rounded-lg border border-border/60 px-3 py-2 focus-within:border-border">
<input
value={commitMessage}
onChange={(event) => setCommitMessage(event.target.value)}
placeholder={t('gitView.commit.messagePlaceholder')}
disabled={isProcessing}
onKeyDown={(event) => {
if (event.key === 'Enter' && !isProcessing) {
event.preventDefault();
void handleCommitAndSwitch();
}
}}
className="min-w-0 flex-1 bg-transparent typography-meta text-foreground outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
onClick={() => { void handleGenerate(); }}
disabled={isProcessing}
aria-label={t('gitView.commit.generate')}
title={t('gitView.commit.generate')}
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-50"
>
{pendingAction === 'generate' ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name="ai-generate-2" className="size-4 text-primary" />
)}
</button>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={pushAfter}
onChange={setPushAfter}
disabled={isProcessing}
ariaLabel={t('gitView.dirtySwitch.pushAfterCommit')}
/>
<span
className="typography-ui-label text-foreground cursor-pointer select-none"
onClick={() => !isProcessing && setPushAfter(!pushAfter)}
>
{t('gitView.dirtySwitch.pushAfterCommit')}
</span>
</div>
<div className="flex items-center justify-between gap-2 pt-1">
<Button
variant="destructive"
size="sm"
onClick={() => { void handleRevertAndSwitch(); }}
disabled={isProcessing}
className="gap-2"
>
{pendingAction === 'revert' ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : null}
{t('gitView.dirtySwitch.revertAndSwitch')}
</Button>
<Button
variant="default"
size="sm"
onClick={() => { void handleCommitAndSwitch(); }}
disabled={isProcessing}
className="gap-2"
>
{pendingAction === 'commit' ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : null}
{t('gitView.dirtySwitch.commitAndSwitch')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -22,10 +22,12 @@ import type {
GitHubChecksSummary,
} from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { useDeviceInfo } from '@/lib/device';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
interface GitHeaderProps {
directory: string;
status: GitStatus | null;
localBranches: string[];
remoteBranches: string[];
@@ -240,6 +242,7 @@ const UpstreamStatusPill: React.FC<UpstreamStatusPillProps> = ({
};
export const GitHeader: React.FC<GitHeaderProps> = ({
directory,
status,
localBranches,
remoteBranches,
@@ -272,6 +275,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
repositoryRoot,
}) => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
if (!status) {
return null;
}
@@ -425,20 +429,23 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
<header className="@container/git-header px-3 py-2 bg-transparent">
<div className="flex items-center justify-between gap-2 min-w-0">
<div className="flex min-w-0 flex-1 items-center gap-1">
{isWorktreeMode ? (
{isWorktreeMode && !isMobile ? (
<WorktreeBranchDisplay
currentBranch={status.current}
onRename={onRenameBranch}
/>
) : (
<BranchSelector
directory={directory}
currentBranch={status.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branchInfo}
currentBranchAhead={status.ahead}
onCheckout={onCheckoutBranch}
onCreate={onCreateBranch}
remotes={remotes}
switchBlockedNotice={(status.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null}
/>
)}
{repositoryOptionsForPicker.length > 0 && onSelectRepository ? (
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { getRecentBranches, rememberRecentBranch } from './recentBranches';
class TestStorage implements Storage {
#values = new Map<string, string>();
get length(): number { return this.#values.size; }
clear(): void { this.#values.clear(); }
getItem(key: string): string | null { return this.#values.get(key) ?? null; }
key(index: number): string | null { return [...this.#values.keys()][index] ?? null; }
removeItem(key: string): void { this.#values.delete(key); }
setItem(key: string, value: string): void { this.#values.set(key, value); }
}
const originalLocalStorage = globalThis.localStorage;
let storage: TestStorage;
beforeEach(() => {
storage = new TestStorage();
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage });
});
afterEach(() => {
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: originalLocalStorage });
});
describe('recent Git branches', () => {
test('persists a branch list for a later UI mount', () => {
rememberRecentBranch('/repo', 'feature/one');
rememberRecentBranch('/repo', 'feature/two');
expect(getRecentBranches('/repo')).toEqual(['feature/two', 'feature/one']);
});
test('keeps only the five most recently used branches', () => {
for (let index = 1; index <= 6; index += 1) {
rememberRecentBranch('/repo', `feature/${index}`);
}
expect(getRecentBranches('/repo')).toEqual([
'feature/6', 'feature/5', 'feature/4', 'feature/3', 'feature/2',
]);
});
});
@@ -0,0 +1,38 @@
import { normalizePath } from '@/lib/pathNormalization';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { z } from 'zod';
const KEY = 'openchamber:recent-git-branches:v1';
const LIMIT = 5;
const entriesSchema = z.record(z.string(), z.array(z.string()));
type Entries = z.infer<typeof entriesSchema>;
const keyFor = (directory: string): string | null => {
const normalized = normalizePath(directory);
return normalized ? `${getRuntimeKey()}:${normalized}` : null;
};
const read = (): Entries => {
try {
const raw = localStorage.getItem(KEY);
const parsed = entriesSchema.safeParse(raw ? JSON.parse(raw) : null);
return parsed.success
? Object.fromEntries(Object.entries(parsed.data).map(([key, branches]) => [key, branches.slice(0, LIMIT)]))
: {};
} catch { return {}; }
};
export const getRecentBranches = (directory: string): string[] => {
const key = keyFor(directory);
return key ? read()[key] ?? [] : [];
};
export const rememberRecentBranch = (directory: string, branch: string): string[] => {
const key = keyFor(directory);
if (!key || !branch) return [];
const entries = read();
const next = [branch, ...(entries[key] ?? []).filter((item) => item !== branch)].slice(0, LIMIT);
try { localStorage.setItem(KEY, JSON.stringify({ ...entries, [key]: next })); } catch { /* convenience only */ }
return next;
};
@@ -295,12 +295,12 @@ describe('settings sync resolution', () => {
const serverTheme = { useSystemTheme: false as const, themeVariant: 'dark' as const, lightThemeId: 'server-light', darkThemeId: 'server-dark' };
test('a non-bootstrap sync (settings save echo) never changes preferences', () => {
expect(resolveThemePreferencesFromSettingsSync({ bootstrap: false, settings: serverTheme }, current)).toBeNull();
expect(resolveThemePreferencesFromSettingsSync({ adoptTheme: false, settings: serverTheme }, current)).toBeNull();
expect(resolveThemePreferencesFromSettingsSync(null, current)).toBeNull();
});
test('a bootstrap sync adopts the server theme', () => {
expect(resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: serverTheme }, current)).toEqual({
expect(resolveThemePreferencesFromSettingsSync({ adoptTheme: true, settings: serverTheme }, current)).toEqual({
themeMode: 'dark',
lightThemeId: 'server-light',
darkThemeId: 'server-dark',
@@ -308,19 +308,19 @@ describe('settings sync resolution', () => {
});
test('theme fields omitted by the server keep the current preferences (not-set is not reset-to-defaults)', () => {
expect(resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: {} }, current)).toBeNull();
expect(resolveThemePreferencesFromSettingsSync({ adoptTheme: true, settings: {} }, current)).toBeNull();
expect(
resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: { useSystemTheme: true } }, current),
resolveThemePreferencesFromSettingsSync({ adoptTheme: true, settings: { useSystemTheme: true } }, current),
).toBeNull();
expect(
resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: { lightThemeId: 'server-light' } }, current),
resolveThemePreferencesFromSettingsSync({ adoptTheme: true, settings: { lightThemeId: 'server-light' } }, current),
).toEqual({ themeMode: 'system', lightThemeId: 'server-light', darkThemeId: 'dark-theme' });
});
test('a bootstrap sync carrying the current preferences resolves to no change', () => {
expect(
resolveThemePreferencesFromSettingsSync(
{ bootstrap: true, settings: { useSystemTheme: true, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' } },
{ adoptTheme: true, settings: { useSystemTheme: true, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' } },
current,
),
).toBeNull();
+2 -2
View File
@@ -201,10 +201,10 @@ type SettingsSyncThemePayload = Pick<
* theme lookup downstream and falls back cosmetically.
*/
export const resolveThemePreferencesFromSettingsSync = (
detail: { bootstrap: boolean; settings: SettingsSyncThemePayload } | null,
detail: { adoptTheme: boolean; settings: SettingsSyncThemePayload } | null,
current: StoredThemePreferences,
): StoredThemePreferences | null => {
if (!detail?.bootstrap) {
if (!detail?.adoptTheme) {
return null;
}
@@ -0,0 +1,11 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
const source = readFileSync(new URL('./useProviderLogo.ts', import.meta.url), 'utf8');
describe('provider logo aliases', () => {
test('maps rotating exe.dev proxy provider IDs to the local exe.dev logo', () => {
expect(source).toContain("compact.startsWith('exe-') ? 'exe-dev' : undefined");
expect(source).toContain('const candidates = [prefixAlias,');
});
});
+2 -1
View File
@@ -45,7 +45,8 @@ const buildLogoCandidates = (providerId: string | null | undefined) => {
const compact = normalized.replace(/[^a-z0-9_\-./:]/g, '');
const primary = compact.split(/[/:]/)[0] || compact;
const candidates = [LOGO_ALIAS.get(compact), LOGO_ALIAS.get(primary), compact, primary]
const prefixAlias = compact.startsWith('exe-') ? 'exe-dev' : undefined;
const candidates = [prefixAlias, LOGO_ALIAS.get(compact), LOGO_ALIAS.get(primary), compact, primary]
.filter((value): value is string => Boolean(value && value.length > 0));
return [...new Set(candidates)];
+6
View File
@@ -147,6 +147,11 @@ export interface GitStatus {
attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null;
}
export interface GitUnpushedBranchCounts {
/** Local commits not present in each branch's configured upstream. */
counts: Record<string, number>;
}
export interface GitDiffResponse {
diff: string;
}
@@ -505,6 +510,7 @@ export interface GitAPI {
revertGitHunk?(directory: string, filePath: string, patch: string): Promise<void>;
isLinkedWorktree(directory: string): Promise<boolean>;
getGitBranches(directory: string): Promise<GitBranch>;
getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise<GitUnpushedBranchCounts>;
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>;
removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }>;
+81 -8
View File
@@ -2,19 +2,52 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
let apiBaseUrl = 'https://remote.example.test';
let tunnelResult: unknown = { localPort: 52418, reused: false };
type TunnelResult = { localPort: number; reused: boolean } | Error;
type DesktopTunnelArgs = { baseUrl?: string; port?: number; relay?: boolean; targetKey?: string };
type RelayEvent = { connectionId: string; remotePort: number; message: { type: string; data?: ArrayBuffer } };
type RelaySocketFixture = {
binaryType: string;
onopen: (() => void) | null;
onmessage: ((event: { data: ArrayBuffer | string }) => void) | null;
onerror: (() => void) | null;
onclose: (() => void) | null;
send: ReturnType<typeof mock>;
close: ReturnType<typeof mock>;
readyState: number;
};
let tunnelResult: TunnelResult = { localPort: 52418, reused: false };
let desktopArgs: DesktopTunnelArgs | undefined;
let relayActive = false;
let openedRelayUrl = '';
let refreshedBaseUrl = '';
let refreshUrlAuth = async (baseUrl: string) => { refreshedBaseUrl = baseUrl; return 'url-token'; };
let relayHandler: ((event: RelayEvent) => void) | null = null;
const relayPosts: Array<{ connectionId: string; message: { type: string; data?: ArrayBuffer } }> = [];
const relaySocket: RelaySocketFixture = { binaryType: 'arraybuffer', onopen: null, onmessage: null, onerror: null, onclose: null, send: mock(() => {}), close: mock(() => {}), readyState: 0 };
mock.module('@/lib/desktopNative', () => ({
invokeDesktopCommand: mock(async () => {
invokeDesktopCommand: mock(async (_command: string, args?: DesktopTunnelArgs) => {
desktopArgs = args;
if (tunnelResult instanceof Error) throw tunnelResult;
return tunnelResult;
}),
listenForDesktopRelayDevTunnels: (handler: typeof relayHandler) => { relayHandler = handler; return true; },
postDesktopRelayDevTunnelMessage: (connectionId: string, message: { type: string; data?: ArrayBuffer }) => relayPosts.push({ connectionId, message }),
}));
mock.module('@/lib/relay/runtime-tunnel', () => ({
isRelayModeActive: () => relayActive,
getActiveRelayTunnel: () => relayActive ? {} : null,
}));
mock.module('@/lib/relay/runtime-socket', () => ({ openRuntimeWebSocket: (url: string) => { openedRelayUrl = url; return relaySocket; } }));
mock.module('@/lib/runtime-auth', () => ({
getRuntimeBearerTokenSync: () => 'token',
getRuntimeExtraHeadersSync: () => ({}),
refreshRuntimeUrlAuthToken: (baseUrl: string) => refreshUrlAuth(baseUrl),
}));
mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => ({ websocket: (path: string) => `openchamber-ui://app${path}&oc_url_token=test` }) }));
mock.module('@/lib/runtime-switch', () => ({
getRuntimeApiBaseUrl: () => apiBaseUrl,
getRuntimeKey: () => relayActive ? 'host:exe' : `url:${apiBaseUrl}`,
subscribeRuntimeEndpointChanged: () => () => {},
}));
@@ -25,23 +58,30 @@ const {
toDisplayUrl,
} = await import('./devTunnel');
const globalScope = globalThis as unknown as { window?: unknown };
const asDesktop = (value: boolean) => {
globalScope.window = value
? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } }
: { location: { href: 'http://127.0.0.1:3901/' } };
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: value
? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } }
: { location: { href: 'http://127.0.0.1:3901/' } },
});
};
describe('loopback navigations against a remote instance', () => {
beforeEach(() => {
apiBaseUrl = 'https://remote.example.test';
tunnelResult = { localPort: 52418, reused: false };
desktopArgs = undefined;
relayActive = false;
relayPosts.length = 0;
openedRelayUrl = '';
refreshedBaseUrl = '';
refreshUrlAuth = async (baseUrl: string) => { refreshedBaseUrl = baseUrl; return 'url-token'; };
asDesktop(true);
});
afterEach(() => {
delete globalScope.window;
Reflect.deleteProperty(globalThis, 'window');
});
test('a page reached through a tunnel keeps its other ports on the host', () => {
@@ -82,6 +122,39 @@ describe('loopback navigations against a remote instance', () => {
expect(failed).toBe(true);
});
test('a relay-only runtime asks Electron for a local relay bridge', async () => {
relayActive = true;
apiBaseUrl = 'openchamber-ui://app';
const resolved = await resolveBrowsableUrl('http://localhost:4322/docs/');
expect(resolved).toBe('http://127.0.0.1:52418/docs/');
expect(desktopArgs?.relay).toBe(true);
expect(desktopArgs?.targetKey).toBe('host:exe');
expect(desktopArgs?.port).toBe(4322);
relayHandler?.({ connectionId: 'connection-1', remotePort: 4322, message: { type: 'connect' } });
await Promise.resolve();
await Promise.resolve();
relaySocket.onopen?.();
expect(refreshedBaseUrl).toBe('openchamber-ui://app');
expect(openedRelayUrl).toContain('/api/dev-tunnel?port=4322&oc_url_token=test');
expect(relayPosts.some((entry) => entry.connectionId === 'connection-1' && entry.message.type === 'ready')).toBe(true);
});
test('a local disconnect during auth does not leave an orphan relay socket', async () => {
relayActive = true;
apiBaseUrl = 'openchamber-ui://app';
let finishAuth = () => {};
refreshUrlAuth = () => new Promise<string>((resolve) => { finishAuth = () => resolve('url-token'); });
relayHandler?.({ connectionId: 'connection-cancelled', remotePort: 4322, message: { type: 'connect' } });
relayHandler?.({ connectionId: 'connection-cancelled', remotePort: 4322, message: { type: 'close' } });
finishAuth();
await Promise.resolve();
await Promise.resolve();
expect(openedRelayUrl).toBe('');
});
test('a local instance resolves its own loopback correctly', () => {
apiBaseUrl = 'http://127.0.0.1:3901';
expect(shouldTunnelLoopbackUrl('http://localhost:4322/docs/')).toBe(false);
+69 -14
View File
@@ -10,17 +10,66 @@
* Everywhere else local runtime, web, mobile the URL is already correct and
* is returned untouched.
*/
import { invokeDesktopCommand } from '@/lib/desktopNative';
import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { invokeDesktopCommand, listenForDesktopRelayDevTunnels, postDesktopRelayDevTunnelMessage } from '@/lib/desktopNative';
import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync, refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getActiveRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel';
import { openRuntimeWebSocket } from '@/lib/relay/runtime-socket';
import type { RelayTunnelWebSocket } from '@/lib/relay/tunnel-client';
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { isLoopbackUrl } from './url';
type TunnelResult = { localPort: number; reused: boolean; url: string };
type TunnelResult = { localPort: number };
/** Keyed by `${baseUrl}|${port}`; the shell owns the real lifetime. */
const localPortByTarget = new Map<string, number>();
/** Reverse map, so a tunnel port never leaks into the address bar or storage. */
const originByLocalPort = new Map<number, string>();
const relaySockets = new Map<string, RelayTunnelWebSocket>();
const pendingRelayConnections = new Set<string>();
const openRelayConnection = async (connectionId: string, remotePort: number): Promise<void> => {
if (!getActiveRelayTunnel()) {
pendingRelayConnections.delete(connectionId);
postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
return;
}
await refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl());
if (!pendingRelayConnections.has(connectionId) || !getActiveRelayTunnel()) return;
const url = getRuntimeUrlResolver().websocket(`/api/dev-tunnel?port=${remotePort}`);
const socket = openRuntimeWebSocket(url);
relaySockets.set(connectionId, socket);
socket.binaryType = 'arraybuffer';
socket.onopen = () => postDesktopRelayDevTunnelMessage(connectionId, { type: 'ready' });
socket.onmessage = (event) => postDesktopRelayDevTunnelMessage(connectionId, { type: 'data', data: event.data instanceof ArrayBuffer ? event.data : new TextEncoder().encode(event.data) });
socket.onerror = () => postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
socket.onclose = () => {
pendingRelayConnections.delete(connectionId);
relaySockets.delete(connectionId);
postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
};
};
listenForDesktopRelayDevTunnels(({ connectionId, remotePort, message }) => {
switch (message.type) {
case 'data': {
const socket = relaySockets.get(connectionId);
if (socket && message.data) socket.send(message.data);
return;
}
case 'close':
pendingRelayConnections.delete(connectionId);
relaySockets.get(connectionId)?.close();
relaySockets.delete(connectionId);
return;
case 'connect':
pendingRelayConnections.add(connectionId);
void openRelayConnection(connectionId, remotePort).catch(() => {
pendingRelayConnections.delete(connectionId);
postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
});
}
});
const isDesktopRuntime = (): boolean => (
typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__)
@@ -69,6 +118,14 @@ const rewriteToLocalPort = (url: string, localPort: number): string => {
}
};
const rememberOriginalOrigin = (url: string, localPort: number): void => {
try {
originByLocalPort.set(localPort, new URL(url).origin);
} catch {
// Unparseable input never reaches here; nothing to record.
}
};
/** Thrown when a remote dev server exists but could not be reached from here. */
export class DevTunnelUnavailableError extends Error {
constructor(message: string) {
@@ -100,11 +157,7 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
const key = `${baseUrl}|${port}`;
const cached = localPortByTarget.get(key);
if (cached) {
try {
originByLocalPort.set(cached, new URL(url).origin);
} catch {
// Unparseable input never reaches here; nothing to record.
}
rememberOriginalOrigin(url, cached);
return rewriteToLocalPort(url, cached);
}
@@ -112,6 +165,8 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
const result = await invokeDesktopCommand<TunnelResult>('desktop_dev_tunnel_open', {
baseUrl,
port,
relay: isRelayModeActive(),
targetKey: getRuntimeKey(),
clientToken: getRuntimeBearerTokenSync(),
requestHeaders: getRuntimeExtraHeadersSync(),
});
@@ -119,11 +174,7 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
throw new DevTunnelUnavailableError(url);
}
localPortByTarget.set(key, result.localPort);
try {
originByLocalPort.set(result.localPort, new URL(url).origin);
} catch {
// Unparseable input never reaches here; nothing to record.
}
rememberOriginalOrigin(url, result.localPort);
return rewriteToLocalPort(url, result.localPort);
} catch (error) {
if (error instanceof DevTunnelUnavailableError) throw error;
@@ -180,6 +231,10 @@ export const toDisplayUrl = (url: string): string => {
const resetDevTunnelCache = (): void => {
localPortByTarget.clear();
originByLocalPort.clear();
pendingRelayConnections.clear();
for (const socket of relaySockets.values()) socket.close();
relaySockets.clear();
void invokeDesktopCommand('desktop_relay_dev_tunnel_close_all').catch(() => {});
};
if (typeof window !== 'undefined') {
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { DesktopHost, HostProbeResult } from './desktopHosts';
let probeResults: Record<string, HostProbeResult> = {};
const probeCalls: string[] = [];
let probeGate: Promise<void> | null = null;
const desktopModule = await import('./desktopHosts');
mock.module('./desktopHosts', () => ({
...desktopModule,
desktopLocalClientTokenGet: async () => 'local-token',
desktopHostProbe: async (url: string) => {
probeCalls.push(url);
if (probeGate) await probeGate;
return probeResults[url] ?? { status: 'unreachable', latencyMs: 0 };
},
}));
const desktopShell = await import('@/lib/desktop');
mock.module('@/lib/desktop', () => ({
...desktopShell,
isDesktopShell: () => true,
isElectronShell: () => false,
}));
const {
getDesktopHostStatusSnapshot,
probeDesktopHosts,
pruneDesktopHostStatuses,
setDesktopHostStatus,
subscribeDesktopHostStatuses,
} = await import('./desktopHostStatus');
const host = (id: string, url: string): DesktopHost => ({ id, label: id, url });
describe('desktop host statuses', () => {
beforeEach(() => {
probeResults = {};
probeCalls.length = 0;
probeGate = null;
pruneDesktopHostStatuses([]);
setDesktopHostStatus('local', { status: 'ok', latencyMs: 1 });
pruneDesktopHostStatuses([]);
});
test('a probe replaces the previous value instead of blanking it first', async () => {
setDesktopHostStatus('remote', { status: 'ok', latencyMs: 12 });
probeResults['https://remote.example'] = { status: 'ok', latencyMs: 40 };
const seen: Array<string | undefined> = [];
const unsubscribe = subscribeDesktopHostStatuses(() => {
seen.push(getDesktopHostStatusSnapshot().byHostId.remote?.status);
});
await probeDesktopHosts([host('remote', 'https://remote.example')]);
unsubscribe();
// Every published snapshot during the run still carried a status; the row
// never falls back to "Checking" while a quiet refresh is running.
expect(seen.every((status) => status !== undefined)).toBe(true);
expect(getDesktopHostStatusSnapshot().byHostId.remote?.latencyMs).toBe(40);
});
test('a fast host is published while a slow one is still in flight', async () => {
probeResults['https://fast.example'] = { status: 'ok', latencyMs: 5 };
probeResults['https://slow.example'] = { status: 'ok', latencyMs: 900 };
let releaseSlow!: () => void;
const slowGate = new Promise<void>((resolve) => { releaseSlow = resolve; });
probeGate = slowGate;
const run = probeDesktopHosts([host('fast', 'https://fast.example'), host('slow', 'https://slow.example')]);
await Promise.resolve();
expect(getDesktopHostStatusSnapshot().isProbing).toBe(true);
releaseSlow();
await run;
expect(getDesktopHostStatusSnapshot().byHostId.fast?.status).toBe('ok');
expect(getDesktopHostStatusSnapshot().byHostId.slow?.status).toBe('ok');
expect(getDesktopHostStatusSnapshot().isProbing).toBe(false);
});
test('pruning keeps local and every configured instance, and forgets the rest', () => {
setDesktopHostStatus('kept', { status: 'ok', latencyMs: 3 });
setDesktopHostStatus('removed', { status: 'ok', latencyMs: 4 });
pruneDesktopHostStatuses(['kept']);
const { byHostId } = getDesktopHostStatusSnapshot();
expect(byHostId.kept?.status).toBe('ok');
expect(byHostId.local?.status).toBe('ok');
expect(byHostId.removed).toBe(undefined);
});
test('a snapshot is a new object per change so subscribers re-render', () => {
const before = getDesktopHostStatusSnapshot();
setDesktopHostStatus('remote', { status: 'auth', latencyMs: 0 });
expect(getDesktopHostStatusSnapshot()).not.toBe(before);
expect(before.byHostId.remote).toBe(undefined);
});
test('a slow older run cannot overwrite a newer result', async () => {
// Startup warm-up, opening the switcher and the refresh button all probe;
// whichever finishes last must not be whichever started first.
probeResults['https://remote.example'] = { status: 'unreachable', latencyMs: 0 };
let releaseSlow!: () => void;
probeGate = new Promise<void>((resolve) => { releaseSlow = resolve; });
const slowRun = probeDesktopHosts([host('remote', 'https://remote.example')]);
await Promise.resolve();
probeGate = null;
probeResults['https://remote.example'] = { status: 'ok', latencyMs: 30 };
await probeDesktopHosts([host('remote', 'https://remote.example')]);
expect(getDesktopHostStatusSnapshot().byHostId.remote?.status).toBe('ok');
releaseSlow();
await slowRun;
expect(getDesktopHostStatusSnapshot().byHostId.remote?.status).toBe('ok');
expect(getDesktopHostStatusSnapshot().byHostId.remote?.latencyMs).toBe(30);
});
test('a status recorded by the switch flow outranks a probe already running', async () => {
probeResults['https://remote.example'] = { status: 'unreachable', latencyMs: 0 };
let releaseSlow!: () => void;
probeGate = new Promise<void>((resolve) => { releaseSlow = resolve; });
const slowRun = probeDesktopHosts([host('remote', 'https://remote.example')]);
await Promise.resolve();
setDesktopHostStatus('remote', { status: 'ok', latencyMs: 7, via: 'relay' });
releaseSlow();
await slowRun;
expect(getDesktopHostStatusSnapshot().byHostId.remote?.status).toBe('ok');
});
});
+186
View File
@@ -0,0 +1,186 @@
import { isDesktopShell, isElectronShell } from '@/lib/desktop';
import {
desktopHostProbe,
desktopHostsGet,
desktopLocalClientTokenGet,
getDesktopHostApiUrl,
normalizeHostUrl,
probeRelayDesktopHost,
type DesktopHost,
type HostProbeResult,
} from '@/lib/desktopHosts';
import { LOCAL_HOST_ID, buildLocalDesktopHost } from '@/lib/desktopCurrentHost';
export type DesktopHostStatus = {
status: HostProbeResult['status'];
latencyMs: number;
/** Which transport the successful probe used (multi-transport hosts). */
via?: 'relay';
};
/** Reachability by instance id. */
type DesktopHostStatusMap = Record<string, DesktopHostStatus>;
type DesktopHostStatusSnapshot = {
byHostId: Readonly<DesktopHostStatusMap>;
/** True while any probe run is in flight, for the refresh spinner. */
isProbing: boolean;
};
/**
* Reachability of every configured instance, owned outside the switcher UI.
*
* The switcher used to hold this in component state, which made the dropdown
* the only thing that could ever learn an instance's status: every open started
* from nothing and showed "Checking" on rows the app had already answered for
* including the instance the app was connected to and actively talking to.
*
* Keeping it here lets startup warm the statuses before the user opens
* anything, and lets a re-probe replace values in place instead of blanking
* them first.
*/
const statuses = new Map<string, DesktopHostStatus>();
// Startup warm-up, opening the switcher and the refresh button can all be in
// flight at once, and a probe's duration varies by an order of magnitude
// between a loopback host and a relay host working through tunnel retries.
// Without ordering, a slow older run lands last and replaces a fresh "ok" with
// its own stale "unreachable". Each host remembers which run owns its status.
let probeRunSequence = 0;
const owningRunByHostId = new Map<string, number>();
let activeProbeRuns = 0;
let snapshot: DesktopHostStatusSnapshot = { byHostId: {}, isProbing: false };
const listeners = new Set<() => void>();
const publishSnapshot = (): void => {
// `useSyncExternalStore` compares snapshots by identity, so each mutation
// publishes a fresh one rather than handing out the live map.
snapshot = { byHostId: Object.fromEntries(statuses), isProbing: activeProbeRuns > 0 };
for (const listener of listeners) {
try {
listener();
} catch {
// A subscriber throwing must not stop the others.
}
}
};
export const subscribeDesktopHostStatuses = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
export const getDesktopHostStatusSnapshot = (): DesktopHostStatusSnapshot => snapshot;
const setStatus = (hostId: string, status: DesktopHostStatus): void => {
statuses.set(hostId, status);
publishSnapshot();
};
/**
* Record a status learned outside a probe run the switch flow probes too, and
* its result is the freshest thing anyone has, so it takes ownership away from
* any probe run still running for that host.
*/
export const setDesktopHostStatus = (hostId: string, status: DesktopHostStatus): void => {
owningRunByHostId.set(hostId, ++probeRunSequence);
setStatus(hostId, status);
};
/**
* Forget instances that are no longer configured. Called with the authoritative
* host list, never with a partially loaded one dropping entries on a list
* that has not finished loading is what made every dropdown open start blank.
*/
export const pruneDesktopHostStatuses = (configuredHostIds: readonly string[]): void => {
const keep = new Set([LOCAL_HOST_ID, ...configuredHostIds]);
let changed = false;
for (const hostId of Array.from(statuses.keys())) {
if (keep.has(hostId)) continue;
statuses.delete(hostId);
owningRunByHostId.delete(hostId);
changed = true;
}
if (changed) publishSnapshot();
};
const isBlockedProbeStatus = (status: HostProbeResult['status']): boolean =>
status === 'unreachable' || status === 'wrong-service' || status === 'incompatible';
const getLocalClientToken = async (): Promise<string> => {
if (!isElectronShell()) return '';
return desktopLocalClientTokenGet().catch(() => '');
};
const probeHost = async (host: DesktopHost, localClientToken: string): Promise<DesktopHostStatus> => {
const clientToken = host.id === LOCAL_HOST_ID ? localClientToken : (host.clientToken || '');
const probeRelayLeg = async (): Promise<DesktopHostStatus> => {
const res = await probeRelayDesktopHost(host.relay!, { clientToken, requestHeaders: host.requestHeaders || null })
.catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
const status: DesktopHostStatus = { status: res.status, latencyMs: res.latencyMs };
// `via` is what renders the "· Relay" suffix, so it marks a reachable host
// only — a failed relay leg says nothing about which transport would work.
if (res.status === 'ok') status.via = 'relay';
return status;
};
// Relay-only host: no HTTP address — probe through the E2EE tunnel.
if (host.relay && !host.apiUrl) return probeRelayLeg();
const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(host) : host.url);
if (!url) return { status: 'unreachable', latencyMs: 0 };
const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: host.requestHeaders || null })
.catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
// Multi-transport host away from its network: the direct leg fails but the
// relay may still reach it.
if (isBlockedProbeStatus(res.status) && host.relay) {
const relayStatus = await probeRelayLeg();
if (relayStatus.status === 'ok') return relayStatus;
}
return { status: res.status, latencyMs: res.latencyMs };
};
/**
* Probe every given instance, publishing each result the moment it lands.
* Waiting for the slowest probe would hold answered rows on "Checking" beside
* one host still working through its relay tunnel retries.
*/
export const probeDesktopHosts = async (hosts: readonly DesktopHost[]): Promise<void> => {
if (!isDesktopShell()) return;
const run = ++probeRunSequence;
for (const host of hosts) owningRunByHostId.set(host.id, run);
activeProbeRuns += 1;
publishSnapshot();
try {
const localClientToken = await getLocalClientToken();
await Promise.all(hosts.map(async (host) => {
const status = await probeHost(host, localClientToken);
// A newer run (or a switch) claimed this host while we were probing.
if (owningRunByHostId.get(host.id) !== run) return;
setStatus(host.id, status);
}));
} finally {
activeProbeRuns -= 1;
publishSnapshot();
}
};
let warmUpStarted = false;
/**
* Learn every instance's status once at startup, so the switcher opens on real
* values instead of probing for the first time under the user's cursor.
*
* Deliberately after the app's own bootstrap: this is background work, and the
* direct legs go through the Electron main process while relay legs open their
* own WebSocket, so neither shares the renderer's connection pool with session
* traffic but the machine's network is still busiest right at launch.
*/
export const warmDesktopHostStatuses = async (): Promise<void> => {
if (warmUpStarted || !isDesktopShell()) return;
warmUpStarted = true;
const config = await desktopHostsGet().catch(() => null);
if (!config) return;
pruneDesktopHostStatuses(config.hosts.map((host) => host.id));
await probeDesktopHosts([buildLocalDesktopHost(config.localOrigin), ...config.hosts]);
};
+104 -2
View File
@@ -1,5 +1,24 @@
import { describe, expect, test } from 'bun:test';
import { desktopHostProbe, desktopHostsGet, desktopHostsSet, importDesktopHostPairing, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts';
import { describe, expect, mock, test } from 'bun:test';
import type { RelayTunnelStatus } from '@/lib/relay/tunnel-client';
import type { DesktopHostRelay } from './desktopHosts';
type TunnelStub = {
fetch: (path: string, init?: RequestInit) => Promise<Response>;
getStatus: () => RelayTunnelStatus;
close: () => void;
};
let nextTunnel: (() => TunnelStub) | null = null;
const tunnelModule = await import('@/lib/relay/tunnel-client');
mock.module('@/lib/relay/tunnel-client', () => ({
...tunnelModule,
createRelayTunnelClient: () => {
if (!nextTunnel) throw new Error('no tunnel stub registered');
return nextTunnel();
},
}));
const { desktopHostProbe, desktopHostsGet, desktopHostsSet, importDesktopHostPairing, probeRelayDesktopHost, redactSensitiveUrl, resolveDesktopHostUrl } = await import('./desktopHosts');
const withDesktopBridge = async <T>(handler: (cmd: string, args: Record<string, unknown>) => unknown | Promise<unknown>, run: () => Promise<T>): Promise<T> => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
@@ -121,3 +140,86 @@ describe('desktop host runtime headers', () => {
});
});
});
describe('probeRelayDesktopHost', () => {
const relay: DesktopHostRelay = {
relayUrl: 'wss://relay.example',
serverId: 'server-a',
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' },
};
const withTimerWindow = async <T>(run: () => Promise<T>): Promise<T> => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { setTimeout: setTimeout.bind(globalThis), clearTimeout: clearTimeout.bind(globalThis) },
});
try {
return await run();
} finally {
if (previousWindow) {
Object.defineProperty(globalThis, 'window', previousWindow);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
}
};
const stubTunnel = (
responses: Array<Response | Error>,
state: RelayTunnelStatus['state'] = 'reconnecting',
) => {
const calls: string[] = [];
let closed = false;
nextTunnel = () => ({
fetch: async (path) => {
calls.push(path);
const next = responses.shift();
if (!next) throw new Error('relay tunnel reset');
if (next instanceof Error) throw next;
return next;
},
getStatus: () => ({ state }),
close: () => { closed = true; },
});
return { calls, isClosed: () => closed };
};
test('a cold first attempt is retried instead of reported unreachable', async () => {
// The tunnel rejects waiters on its first failed connect and then
// reconnects; the probe must span that, not read it as an unreachable host.
const tunnel = stubTunnel([
new Error('relay tunnel reset: connection failed'),
new Response('{}', { status: 200 }),
new Response('{}', { status: 200 }),
]);
const result = await withTimerWindow(() => probeRelayDesktopHost(relay, { clientToken: 'token' }));
expect(result.status).toBe('ok');
expect(tunnel.calls).toEqual(['/health', '/health', '/auth/session']);
expect(tunnel.isClosed()).toBe(true);
});
test('a terminal tunnel state ends the probe without retrying', async () => {
// Auth failed / duplicate client / limit reached will not resolve by waiting.
const tunnel = stubTunnel([new Error('relay connection replaced by another client')], 'error');
const result = await withTimerWindow(() => probeRelayDesktopHost(relay, { clientToken: 'token' }));
expect(result.status).toBe('unreachable');
expect(tunnel.calls).toEqual(['/health']);
});
test('a rejected client token is reported as auth, not unreachable', async () => {
const tunnel = stubTunnel([
new Response('{}', { status: 200 }),
new Response('{}', { status: 401 }),
]);
const result = await withTimerWindow(() => probeRelayDesktopHost(relay, { clientToken: 'stale' }));
expect(result.status).toBe('auth');
expect(tunnel.calls).toEqual(['/health', '/auth/session']);
});
});
+51 -7
View File
@@ -408,14 +408,18 @@ export const desktopInstallIdGet = async (): Promise<string> => {
};
const RELAY_PROBE_TIMEOUT_MS = 8_000;
// Whole-probe budget, spanning the tunnel's own reconnect attempts.
const RELAY_PROBE_DEADLINE_MS = 15_000;
const RELAY_PROBE_RETRY_DELAY_MS = 400;
const fetchRelayProbe = async (
tunnel: ReturnType<typeof createRelayTunnelClient>,
path: string,
timeoutMs: number,
init?: RequestInit,
): Promise<Response> => {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), RELAY_PROBE_TIMEOUT_MS);
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
try {
return await tunnel.fetch(path, { ...init, signal: controller.signal });
} finally {
@@ -423,13 +427,52 @@ const fetchRelayProbe = async (
}
};
/**
* Reach the host, letting the tunnel's own reconnect do the work.
*
* The tunnel rejects everything waiting on its channel the moment ONE connect
* attempt fails, even though it has already scheduled the next one with
* backoff. That is right for app traffic `runtime-fetch` retries for itself
* but it made a one-shot probe report a durable red "Unreachable" for a host
* that answers when the user presses refresh a second later. A cold start is
* exactly when that first attempt loses: DNS and TLS to the relay are cold, the
* remote host may still be re-establishing its control connection, and the
* probe competes with the app's own bootstrap traffic.
*
* A terminal tunnel state (auth failed, duplicate client, limit reached) will
* not resolve by waiting, so it ends the probe immediately.
*/
const fetchRelayProbeUntilDeadline = async (
tunnel: ReturnType<typeof createRelayTunnelClient>,
path: string,
deadline: number,
init?: RequestInit,
): Promise<Response> => {
for (;;) {
// Every attempt is capped by what is LEFT of the budget, not by the full
// per-request timeout: an attempt started just under the deadline would
// otherwise run the whole 8s past it, and the switch flow waits on this.
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) throw new Error('relay probe deadline exceeded');
try {
return await fetchRelayProbe(tunnel, path, Math.min(RELAY_PROBE_TIMEOUT_MS, remainingMs), init);
} catch (error) {
if (tunnel.getStatus().state === 'error') throw error;
if (Date.now() >= deadline) throw error;
await new Promise((resolve) => window.setTimeout(resolve, RELAY_PROBE_RETRY_DELAY_MS));
}
}
};
/**
* Reachability and client-auth check for a relay host: open a throwaway E2EE
* tunnel, verify `/health`, then verify `/auth/session` with the saved bearer.
* Relay hosts have no HTTP address for `desktopHostProbe`. Hard timeout: a
* ghost relay registration (relay lost the host, host doesn't know) leaves the
* tunnel in `connecting` forever the probe must report unreachable instead
* of hanging every status/switch flow with it.
* Relay hosts have no HTTP address for `desktopHostProbe`. Bounded by
* `RELAY_PROBE_DEADLINE_MS`: a ghost relay registration (relay lost the host,
* host doesn't know) leaves the tunnel reconnecting forever the probe must
* report unreachable rather than hang every status/switch flow with it while
* still spanning enough reconnect attempts that a cold first attempt is not
* mistaken for an unreachable instance.
*/
export const probeRelayDesktopHost = async (
relay: DesktopHostRelay,
@@ -444,9 +487,10 @@ export const probeRelayDesktopHost = async (
hostEncPubJwk: relay.hostEncPubJwk,
});
const startedAt = Date.now();
const deadline = startedAt + RELAY_PROBE_DEADLINE_MS;
let keep = false;
try {
const response = await fetchRelayProbe(tunnel, '/health');
const response = await fetchRelayProbeUntilDeadline(tunnel, '/health', deadline);
if (!response.ok) return { status: 'unreachable', latencyMs: 0 };
const headers = new Headers({ Accept: 'application/json' });
for (const [name, value] of Object.entries(options?.requestHeaders || {})) {
@@ -454,7 +498,7 @@ export const probeRelayDesktopHost = async (
}
const clientToken = options?.clientToken?.trim();
if (clientToken) headers.set('Authorization', `Bearer ${clientToken}`);
const sessionResponse = await fetchRelayProbe(tunnel, '/auth/session', { headers });
const sessionResponse = await fetchRelayProbeUntilDeadline(tunnel, '/auth/session', deadline, { headers });
if (sessionResponse.status === 401 || sessionResponse.status === 403) {
return { status: 'auth', latencyMs: Math.max(0, Date.now() - startedAt) };
}
+28
View File
@@ -1,6 +1,34 @@
import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop';
type InvokeArgs = Record<string, unknown>;
type RelayDevTunnelData = ArrayBuffer | Uint8Array;
type RelayDevTunnelMessage = { type: 'connect' | 'ready' | 'data' | 'close'; data?: RelayDevTunnelData };
type RelayDevTunnelEvent = { connectionId: string; remotePort: number; message: RelayDevTunnelMessage };
type RelayDevTunnelBridge = {
relayDevTunnelListen?: (handler: (event: RelayDevTunnelEvent) => void) => void;
relayDevTunnelPost?: (connectionId: string, message: RelayDevTunnelMessage) => void;
};
declare global {
interface Window {
__OPENCHAMBER_DESKTOP__?: RelayDevTunnelBridge;
}
}
const getRelayDevTunnelBridge = (): RelayDevTunnelBridge | null => {
return globalThis.window?.__OPENCHAMBER_DESKTOP__ ?? null;
};
export const listenForDesktopRelayDevTunnels = (handler: (event: RelayDevTunnelEvent) => void): boolean => {
const bridge = getRelayDevTunnelBridge();
if (!bridge?.relayDevTunnelListen) return false;
bridge.relayDevTunnelListen(handler);
return true;
};
export const postDesktopRelayDevTunnelMessage = (connectionId: string, message: RelayDevTunnelMessage): void => {
getRelayDevTunnelBridge()?.relayDevTunnelPost?.(connectionId, message);
};
export const invokeDesktopCommand = async <TValue = unknown>(
command: string,
+4
View File
@@ -4,6 +4,10 @@ const loadedFaces = new Set<string>();
const pendingFaces = new Map<string, Promise<void>>();
const buildFontUrl = (source: FontFaceSource, weight: number) => {
if ('urls' in source) {
return source.urls[weight];
}
const packageName = encodeURIComponent(source.packageName).replace('%40', '@').replace('%2F', '/');
return `https://cdn.jsdelivr.net/npm/${packageName}/files/${source.filePrefix}-latin-${weight}-normal.woff2`;
};
+28 -4
View File
@@ -1,14 +1,23 @@
export type UiFontOption = 'inter' | 'geist-sans' | 'atkinson-hyperlegible' | 'source-sans-3' | 'roboto' | 'noto-sans' | 'dm-sans' | 'manrope' | 'system';
export type UiFontOption = 'inter' | 'fixel' | 'geist-sans' | 'atkinson-hyperlegible' | 'source-sans-3' | 'roboto' | 'noto-sans' | 'dm-sans' | 'manrope' | 'system';
export type MonoFontOption = 'jetbrains-mono' | 'fira-code' | 'geist-mono' | 'commit-mono' | 'source-code-pro' | 'cascadia-code' | 'roboto-mono' | 'iosevka' | 'system-mono';
export interface FontFaceSource {
interface FontFaceSourceBase {
family: string;
packageName: string;
filePrefix: string;
weights: number[];
}
interface FontsourceFaceSource extends FontFaceSourceBase {
packageName: string;
filePrefix: string;
}
interface DirectFontFaceSource extends FontFaceSourceBase {
urls: Record<number, string>;
}
export type FontFaceSource = FontsourceFaceSource | DirectFontFaceSource;
export interface FontOptionDefinition<T extends string> {
id: T;
label: string;
@@ -26,6 +35,21 @@ export const UI_FONT_OPTIONS: FontOptionDefinition<UiFontOption>[] = [
stack: '"Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
source: { family: 'Inter', packageName: '@fontsource/inter', filePrefix: 'inter', weights: [400, 500, 600] }
},
{
id: 'fixel',
label: 'Fixel Text',
description: 'Humanist geometric sans-serif with full Ukrainian support.',
stack: '"Fixel Text", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
source: {
family: 'Fixel Text',
weights: [400, 500, 600],
urls: {
400: 'https://cdn.jsdelivr.net/gh/MacPaw/Fixel@f6ee910e98add47e830db87f1a754130506c11a2/fonts/webfonts/FixelText-Regular.woff2',
500: 'https://cdn.jsdelivr.net/gh/MacPaw/Fixel@f6ee910e98add47e830db87f1a754130506c11a2/fonts/webfonts/FixelText-Medium.woff2',
600: 'https://cdn.jsdelivr.net/gh/MacPaw/Fixel@f6ee910e98add47e830db87f1a754130506c11a2/fonts/webfonts/FixelText-SemiBold.woff2'
}
}
},
{
id: 'geist-sans',
label: 'Geist Sans',
+6
View File
@@ -214,6 +214,12 @@ export async function getGitBranches(directory: string): Promise<import('./api/t
return gitHttp.getGitBranches(directory);
}
export async function getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise<import('./api/types').GitUnpushedBranchCounts> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getGitUnpushedBranchCounts(directory, branches);
return gitHttp.getGitUnpushedBranchCounts(directory, branches);
}
export async function deleteGitBranch(directory: string, payload: import('./api/types').GitDeleteBranchPayload): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtimeStatusMutation(directory, runtime.deleteGitBranch(directory, payload));
+11
View File
@@ -7,6 +7,7 @@ import type {
GitFileDiffResponse,
GetGitFileDiffOptions,
GitBranch,
GitUnpushedBranchCounts,
GitDeleteBranchPayload,
GitDeleteRemoteBranchPayload,
GitRemoveRemotePayload,
@@ -493,6 +494,16 @@ export async function getGitBranches(directory: string): Promise<GitBranch> {
return response.json();
}
export async function getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise<GitUnpushedBranchCounts> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/branch-push-status`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branches }),
});
if (!response.ok) throw new Error(`Failed to get branch push status: ${response.statusText}`);
return response.json();
}
export async function deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> {
if (!payload?.branch) {
throw new Error('branch is required to delete a branch');
@@ -2136,6 +2136,8 @@ export const settingsDict = {
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Warteschlange',
'settings.providers.page.quotaCredentials.accessToken': 'Zugriffstoken',
'settings.providers.page.quotaCredentials.usageToken': 'Nutzungs-API-Token',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Führen Sie diesen Befehl im Terminal aus und fügen Sie dann das Token unten ein. Es kann nur die LLM-Guthabennutzung lesen und läuft nach 30 Tagen ab.',
'settings.providers.page.quotaCredentials.refreshToken': 'Aktualisierungstoken',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Token einfügen',
'settings.view.nav.group.general': 'OpenChamber',
+16
View File
@@ -699,6 +699,20 @@ export const dict = {
'gitView.commit.stageFilesHint': 'Stagen Sie Dateien, um Commit zu aktivieren.',
'gitView.commit.title': 'Commit',
'gitView.common.cancel': 'Abbrechen',
'gitView.branch.switchBlockedNotice': 'Nicht committete Änderungen — vor dem Wechsel folgt ein Commit-oder-Verwerfen-Schritt.',
'gitView.branch.unpushedSingle': '1 Commit nicht gepusht',
'gitView.branch.unpushedPlural': '{count} Commits nicht gepusht',
'gitView.branch.recentBranches': 'Kürzliche Branches',
'gitView.dirtySwitch.title': 'Nicht committete Änderungen',
'gitView.dirtySwitch.descriptionSingle': 'Der Wechsel zu {branch} ist angehalten, damit die geänderte Datei nicht verloren geht. Zuerst committen oder verwerfen.',
'gitView.dirtySwitch.descriptionPlural': 'Der Wechsel zu {branch} ist angehalten, damit die {count} geänderten Dateien nicht verloren gehen. Zuerst committen oder verwerfen.',
'gitView.dirtySwitch.commitAndSwitch': 'Committen und wechseln',
'gitView.dirtySwitch.committedNotPushed': 'Auf {branch} committet. Der Commit ist nur lokal — er wurde nicht gepusht.',
'gitView.dirtySwitch.pushAfterCommit': 'Nach dem Commit pushen',
'gitView.dirtySwitch.pushFailed': 'Committet, aber der Push ist fehlgeschlagen — der Branch wurde nicht gewechselt.',
'gitView.dirtySwitch.actionFailed': 'Die Aktion ist fehlgeschlagen; der Branch wurde nicht gewechselt.',
'gitView.dirtySwitch.revertAndSwitch': 'Verwerfen und wechseln',
'gitView.dirtySwitch.revertIncomplete': 'Einige Änderungen konnten nicht verworfen werden, der Branch wurde nicht gewechselt.',
'gitView.common.close': 'Schließen',
'gitView.common.done': 'Fertig',
'gitView.common.processing': 'Verarbeitung läuft...',
@@ -1429,6 +1443,8 @@ export const dict = {
'chat.autoReview.reviewSessionLabel': 'Überprüfungssitzung',
'chat.autoReview.actions.open': 'Öffnen',
'chat.autoReview.actions.stop': 'Stoppen',
'chat.draftDirtyNotice.tooltip': 'Dieser Branch hat nicht committete Dateien.\nDie neue Session sieht sie. Ein Commit oder ein Worktree hält sie getrennt.',
'chat.draftDirtyNotice.indicatorAria': 'Nicht committete Änderungen in diesem Verzeichnis',
'diffView.hunk.label': 'Stücke',
'diffView.hunk.stage': 'Zu Staging hinzufügen',
'diffView.hunk.unstage': 'Aus Staging entfernen',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Delete',
'settings.providers.page.quotaCredentials.saved': '{provider} credentials saved.',
'settings.providers.page.quotaCredentials.accessToken': 'Access token',
'settings.providers.page.quotaCredentials.usageToken': 'Usage API token',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Run this command in your terminal, then paste the token below. It can only read LLM credit usage and expires after 30 days.',
'settings.providers.page.quotaCredentials.refreshToken': 'Refresh token',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Paste token',
'settings.providers.page.openCodeGo.saveFailed': 'Could not validate OpenCode Go credentials.',
+16
View File
@@ -795,6 +795,20 @@ export const dict = {
'gitView.commit.stageFilesHint': 'Stage files to enable commit.',
'gitView.commit.title': 'Commit',
'gitView.common.cancel': 'Cancel',
'gitView.branch.switchBlockedNotice': 'Uncommitted changes — switching opens a commit-or-revert step first.',
'gitView.branch.unpushedSingle': '1 commit not pushed',
'gitView.branch.unpushedPlural': '{count} commits not pushed',
'gitView.branch.recentBranches': 'Recent branches',
'gitView.dirtySwitch.title': 'Uncommitted changes',
'gitView.dirtySwitch.descriptionSingle': 'Switching to {branch} is paused so your changed file is not lost. Commit it, or revert it first.',
'gitView.dirtySwitch.descriptionPlural': 'Switching to {branch} is paused so your {count} changed files are not lost. Commit them, or revert them first.',
'gitView.dirtySwitch.commitAndSwitch': 'Commit and switch',
'gitView.dirtySwitch.committedNotPushed': 'Committed to {branch}. The commit is local only — it has not been pushed.',
'gitView.dirtySwitch.pushAfterCommit': 'Push after commit',
'gitView.dirtySwitch.pushFailed': 'Committed, but the push failed — the branch was not switched.',
'gitView.dirtySwitch.actionFailed': 'The action failed; the branch was not switched.',
'gitView.dirtySwitch.revertAndSwitch': 'Revert and switch',
'gitView.dirtySwitch.revertIncomplete': 'Some changes could not be reverted, so the branch was not switched.',
'gitView.common.close': 'Close',
'gitView.common.done': 'Done',
'gitView.common.processing': 'Processing...',
@@ -1626,6 +1640,8 @@ export const dict = {
'chat.autoReview.reviewSessionLabel': 'Review session',
'chat.autoReview.actions.open': 'Open',
'chat.autoReview.actions.stop': 'Stop',
'chat.draftDirtyNotice.tooltip': 'This branch has uncommitted files.\nThe new session will see them. A commit or a worktree keeps them separate.',
'chat.draftDirtyNotice.indicatorAria': 'Uncommitted changes in this directory',
'diffView.hunk.label': 'Hunks',
'diffView.hunk.stage': 'Stage',
'diffView.hunk.unstage': 'Unstage',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Eliminar',
'settings.providers.page.quotaCredentials.saved': 'Credenciales de {provider} guardadas.',
'settings.providers.page.quotaCredentials.accessToken': 'Token de acceso',
'settings.providers.page.quotaCredentials.usageToken': 'Token de API de uso',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Ejecuta este comando en tu terminal y pega el token abajo. Solo puede leer el uso de créditos de LLM y caduca después de 30 días.',
'settings.providers.page.quotaCredentials.refreshToken': 'Token de actualización',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Pega el token',
'settings.providers.page.openCodeGo.saveFailed': 'No se pudieron validar las credenciales de OpenCode Go.',
+16
View File
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
"gitView.commit.stageFilesHint": "Prepara archivos para habilitar el commit.",
"gitView.commit.title": "Commit",
"gitView.common.cancel": "Cancelar",
'gitView.branch.switchBlockedNotice': 'Cambios sin confirmar: antes de cambiar de rama se ofrece confirmar o revertir.',
'gitView.branch.unpushedSingle': '1 commit sin push',
'gitView.branch.unpushedPlural': '{count} commits sin push',
'gitView.branch.recentBranches': 'Ramas recientes',
'gitView.dirtySwitch.title': 'Cambios sin confirmar',
'gitView.dirtySwitch.descriptionSingle': 'El cambio a {branch} está en pausa para no perder tu archivo modificado. Confírmalo o reviértelo primero.',
'gitView.dirtySwitch.descriptionPlural': 'El cambio a {branch} está en pausa para no perder tus {count} archivos modificados. Confírmalos o reviértelos primero.',
'gitView.dirtySwitch.commitAndSwitch': 'Confirmar y cambiar',
'gitView.dirtySwitch.committedNotPushed': 'Confirmado en {branch}. El commit es solo local: no se ha hecho push.',
'gitView.dirtySwitch.pushAfterCommit': 'Hacer push después del commit',
'gitView.dirtySwitch.pushFailed': 'Se confirmó, pero el push falló: no se cambió de rama.',
'gitView.dirtySwitch.actionFailed': 'La acción falló; no se cambió de rama.',
'gitView.dirtySwitch.revertAndSwitch': 'Revertir y cambiar',
'gitView.dirtySwitch.revertIncomplete': 'Algunos cambios no se pudieron revertir, así que no se cambió de rama.',
"gitView.common.close": "Cerrar",
"gitView.common.done": "Hecho",
"gitView.common.processing": "Procesando...",
@@ -1604,6 +1618,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': 'Sesión de revisión',
'chat.autoReview.actions.open': 'Abrir',
'chat.autoReview.actions.stop': 'Detener',
'chat.draftDirtyNotice.tooltip': 'Esta rama tiene archivos sin confirmar.\nLa nueva sesión los verá. Un commit o un worktree los mantiene separados.',
'chat.draftDirtyNotice.indicatorAria': 'Cambios sin confirmar en este directorio',
"diffView.hunk.label": "Fragmentos",
"diffView.hunk.stage": "Preparar",
"diffView.hunk.unstage": "Quitar",
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Supprimer',
'settings.providers.page.quotaCredentials.saved': 'Identifiants de {provider} enregistrés.',
'settings.providers.page.quotaCredentials.accessToken': 'Jeton daccès',
'settings.providers.page.quotaCredentials.usageToken': 'Jeton API dutilisation',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Exécutez cette commande dans votre terminal, puis collez le jeton ci-dessous. Il peut uniquement lire lutilisation des crédits LLM et expire après 30 jours.',
'settings.providers.page.quotaCredentials.refreshToken': 'Jeton dactualisation',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Collez le jeton',
'settings.providers.page.openCodeGo.saveFailed': 'Impossible de valider les identifiants OpenCode Go.',
+16
View File
@@ -618,6 +618,20 @@ export const dict = {
'gitView.commit.stageFilesHint': 'Ajoutez des fichiers à lindex pour activer le commit.',
'gitView.commit.title': 'Commettre',
'gitView.common.cancel': 'Annuler',
'gitView.branch.switchBlockedNotice': 'Modifications non commitées — le changement passe dabord par un commit ou une annulation.',
'gitView.branch.unpushedSingle': '1 commit non poussé',
'gitView.branch.unpushedPlural': '{count} commits non poussés',
'gitView.branch.recentBranches': 'Branches récentes',
'gitView.dirtySwitch.title': 'Modifications non commitées',
'gitView.dirtySwitch.descriptionSingle': 'Le passage à {branch} est suspendu pour ne pas perdre votre fichier modifié. Commitez-le ou annulez-le dabord.',
'gitView.dirtySwitch.descriptionPlural': 'Le passage à {branch} est suspendu pour ne pas perdre vos {count} fichiers modifiés. Commitez-les ou annulez-les dabord.',
'gitView.dirtySwitch.commitAndSwitch': 'Commiter et changer',
'gitView.dirtySwitch.committedNotPushed': 'Commité sur {branch}. Le commit est local uniquement — il na pas été poussé.',
'gitView.dirtySwitch.pushAfterCommit': 'Pousser après le commit',
'gitView.dirtySwitch.pushFailed': 'Commité, mais le push a échoué — la branche na pas été changée.',
'gitView.dirtySwitch.actionFailed': 'Laction a échoué ; la branche na pas été changée.',
'gitView.dirtySwitch.revertAndSwitch': 'Annuler et changer',
'gitView.dirtySwitch.revertIncomplete': 'Certaines modifications nont pas pu être annulées, la branche na donc pas été changée.',
'gitView.common.close': 'Fermer',
'gitView.common.done': 'Fait',
'gitView.common.processing': 'Traitement...',
@@ -1390,6 +1404,8 @@ export const dict = {
'chat.autoReview.reviewSessionLabel': 'Session de revue',
'chat.autoReview.actions.open': 'Ouvrir',
'chat.autoReview.actions.stop': 'Arrêter',
'chat.draftDirtyNotice.tooltip': 'Cette branche a des fichiers non commités.\nLa nouvelle session les verra. Un commit ou un worktree les garde séparés.',
'chat.draftDirtyNotice.indicatorAria': 'Modifications non commitées dans ce répertoire',
'diffView.hunk.label': 'Sections',
'diffView.hunk.stage': 'Préparer',
'diffView.hunk.unstage': 'Retirer',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': '削除',
'settings.providers.page.quotaCredentials.saved': '{provider} の認証情報を保存しました。',
'settings.providers.page.quotaCredentials.accessToken': 'アクセストークン',
'settings.providers.page.quotaCredentials.usageToken': '使用量 API トークン',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'このコマンドをターミナルで実行し、下にトークンを貼り付けてください。LLM クレジット使用量の読み取りのみが可能で、30 日後に期限切れになります。',
'settings.providers.page.quotaCredentials.refreshToken': '更新トークン',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'トークンを貼り付け',
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go の認証情報を検証できませんでした。',
+16
View File
@@ -793,6 +793,20 @@ export const dict: Record<I18nKey, string> = {
'gitView.commit.stageFilesHint': 'ファイルをステージするとコミットが有効になります。',
'gitView.commit.title': 'コミット',
'gitView.common.cancel': 'キャンセル',
'gitView.branch.switchBlockedNotice': '未コミットの変更があります — 切り替え前にコミットまたは破棄の手順が入ります。',
'gitView.branch.unpushedSingle': '未プッシュのコミットが1件',
'gitView.branch.unpushedPlural': '未プッシュのコミットが{count}件',
'gitView.branch.recentBranches': '最近のブランチ',
'gitView.dirtySwitch.title': '未コミットの変更',
'gitView.dirtySwitch.descriptionSingle': '変更したファイルを失わないよう、{branch}への切り替えを一時停止しました。先にコミットするか破棄してください。',
'gitView.dirtySwitch.descriptionPlural': '変更した{count}件のファイルを失わないよう、{branch}への切り替えを一時停止しました。先にコミットするか破棄してください。',
'gitView.dirtySwitch.commitAndSwitch': 'コミットして切り替え',
'gitView.dirtySwitch.committedNotPushed': '{branch}にコミットしました。このコミットはローカルのみで、プッシュされていません。',
'gitView.dirtySwitch.pushAfterCommit': 'コミット後にプッシュ',
'gitView.dirtySwitch.pushFailed': 'コミットしましたが、プッシュに失敗したためブランチは切り替えませんでした。',
'gitView.dirtySwitch.actionFailed': '操作に失敗したため、ブランチは切り替えませんでした。',
'gitView.dirtySwitch.revertAndSwitch': '破棄して切り替え',
'gitView.dirtySwitch.revertIncomplete': '一部の変更を破棄できなかったため、ブランチは切り替えませんでした。',
'gitView.common.close': '閉じる',
'gitView.common.done': '完了',
'gitView.common.processing': '処理中...',
@@ -1631,6 +1645,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': 'レビューセッション',
'chat.autoReview.actions.open': '開く',
'chat.autoReview.actions.stop': '停止',
'chat.draftDirtyNotice.tooltip': 'このブランチには未コミットのファイルがあります。\n新しいセッションからも見えます。コミットまたはワークツリーで分けられます。',
'chat.draftDirtyNotice.indicatorAria': 'このディレクトリに未コミットの変更があります',
'rightSidebar.contextNotesTodo.plan.defaultTitle': '計画',
'rightSidebar.contextNotesTodo.empty.selectProject': 'プロジェクトを選択してメモとTODOを追加します。',
'rightSidebar.contextNotesTodo.notes.placeholder': 'コンテキスト、リマインダー、リンクを記録',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': '삭제',
'settings.providers.page.quotaCredentials.saved': '{provider} 인증 정보를 저장했습니다.',
'settings.providers.page.quotaCredentials.accessToken': '액세스 토큰',
'settings.providers.page.quotaCredentials.usageToken': '사용량 API 토큰',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': '터미널에서 이 명령을 실행한 다음 아래에 토큰을 붙여 넣으세요. LLM 크레딧 사용량만 읽을 수 있으며 30일 후 만료됩니다.',
'settings.providers.page.quotaCredentials.refreshToken': '새로 고침 토큰',
'settings.providers.page.quotaCredentials.tokenPlaceholder': '토큰 붙여넣기',
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go 인증 정보를 검증할 수 없습니다.',
+16
View File
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
'gitView.commit.stageFilesHint': '커밋하려면 파일을 스테이징하세요.',
'gitView.commit.title': '커밋',
'gitView.common.cancel': '취소',
'gitView.branch.switchBlockedNotice': '커밋되지 않은 변경 사항이 있습니다 — 전환 전에 커밋 또는 되돌리기 단계가 먼저 열립니다.',
'gitView.branch.unpushedSingle': '푸시되지 않은 커밋 1개',
'gitView.branch.unpushedPlural': '푸시되지 않은 커밋 {count}개',
'gitView.branch.recentBranches': '최근 브랜치',
'gitView.dirtySwitch.title': '커밋되지 않은 변경 사항',
'gitView.dirtySwitch.descriptionSingle': '변경된 파일을 잃지 않도록 {branch}(으)로의 전환을 잠시 멈췄습니다. 먼저 커밋하거나 되돌리세요.',
'gitView.dirtySwitch.descriptionPlural': '변경된 파일 {count}개를 잃지 않도록 {branch}(으)로의 전환을 잠시 멈췄습니다. 먼저 커밋하거나 되돌리세요.',
'gitView.dirtySwitch.commitAndSwitch': '커밋하고 전환',
'gitView.dirtySwitch.committedNotPushed': '{branch}에 커밋했습니다. 이 커밋은 로컬 전용이며 푸시되지 않았습니다.',
'gitView.dirtySwitch.pushAfterCommit': '커밋 후 푸시',
'gitView.dirtySwitch.pushFailed': '커밋했지만 푸시에 실패하여 브랜치를 전환하지 않았습니다.',
'gitView.dirtySwitch.actionFailed': '작업이 실패하여 브랜치를 전환하지 않았습니다.',
'gitView.dirtySwitch.revertAndSwitch': '되돌리고 전환',
'gitView.dirtySwitch.revertIncomplete': '일부 변경 사항을 되돌리지 못해 브랜치를 전환하지 않았습니다.',
'gitView.common.close': '닫기',
'gitView.common.done': '완료',
'gitView.common.processing': '처리 중…',
@@ -1628,6 +1642,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': '리뷰 세션',
'chat.autoReview.actions.open': '열기',
'chat.autoReview.actions.stop': '중지',
'chat.draftDirtyNotice.tooltip': '이 브랜치에는 커밋되지 않은 파일이 있습니다.\n새 세션에서도 보입니다. 커밋 또는 워크트리로 분리할 수 있습니다.',
'chat.draftDirtyNotice.indicatorAria': '이 디렉터리에 커밋되지 않은 변경 사항이 있습니다',
'diffView.hunk.label': '허크',
'diffView.hunk.stage': '스테이지',
'diffView.hunk.unstage': '스테이지 해제',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Usuń',
'settings.providers.page.quotaCredentials.saved': 'Dane uwierzytelniające {provider} zostały zapisane.',
'settings.providers.page.quotaCredentials.accessToken': 'Token dostępu',
'settings.providers.page.quotaCredentials.usageToken': 'Token API użycia',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Uruchom to polecenie w terminalu, a następnie wklej token poniżej. Może on tylko odczytywać użycie środków LLM i wygasa po 30 dniach.',
'settings.providers.page.quotaCredentials.refreshToken': 'Token odświeżania',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Wklej token',
'settings.providers.page.openCodeGo.saveFailed': 'Nie udało się sprawdzić danych OpenCode Go.',
+16
View File
@@ -1844,6 +1844,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': 'Sesja review',
'chat.autoReview.actions.open': 'Otwórz',
'chat.autoReview.actions.stop': 'Zatrzymaj',
'chat.draftDirtyNotice.tooltip': 'Ta gałąź ma niezacommitowane pliki.\nNowa sesja będzie je widzieć. Commit albo worktree trzyma je osobno.',
'chat.draftDirtyNotice.indicatorAria': 'Niezacommitowane zmiany w tym katalogu',
'diffView.hunk.label': 'Fragmenty',
'diffView.hunk.stage': 'Przygotuj',
'diffView.hunk.unstage': 'Cofnij',
@@ -2106,6 +2108,20 @@ export const dict: Record<I18nKey, string> = {
'gitView.commit.stageFilesHint': 'Dodaj pliki do indeksu, aby włączyć commit.',
'gitView.commit.title': 'Commit',
'gitView.common.cancel': 'Anuluj',
'gitView.branch.switchBlockedNotice': 'Niezacommitowane zmiany — przed przełączeniem pojawi się krok commit lub cofnięcie.',
'gitView.branch.unpushedSingle': '1 niewypchnięty commit',
'gitView.branch.unpushedPlural': 'Niewypchnięte commity: {count}',
'gitView.branch.recentBranches': 'Ostatnie gałęzie',
'gitView.dirtySwitch.title': 'Niezacommitowane zmiany',
'gitView.dirtySwitch.descriptionSingle': 'Przełączenie na {branch} wstrzymano, aby nie stracić zmienionego pliku. Najpierw go zacommituj lub cofnij.',
'gitView.dirtySwitch.descriptionPlural': 'Przełączenie na {branch} wstrzymano, aby nie stracić {count} zmienionych plików. Najpierw je zacommituj lub cofnij.',
'gitView.dirtySwitch.commitAndSwitch': 'Zacommituj i przełącz',
'gitView.dirtySwitch.committedNotPushed': 'Zacommitowano na {branch}. Commit jest tylko lokalny — nie został wypchnięty.',
'gitView.dirtySwitch.pushAfterCommit': 'Wypchnij po commicie',
'gitView.dirtySwitch.pushFailed': 'Zacommitowano, ale push się nie powiódł — gałąź nie została przełączona.',
'gitView.dirtySwitch.actionFailed': 'Akcja nie powiodła się; gałąź nie została przełączona.',
'gitView.dirtySwitch.revertAndSwitch': 'Cofnij i przełącz',
'gitView.dirtySwitch.revertIncomplete': 'Nie udało się cofnąć części zmian, więc gałąź nie została przełączona.',
'gitView.common.close': 'Zamknij',
'gitView.common.done': 'Gotowe',
'gitView.common.processing': 'Przetwarzanie...',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Excluir',
'settings.providers.page.quotaCredentials.saved': 'Credenciais de {provider} salvas.',
'settings.providers.page.quotaCredentials.accessToken': 'Token de acesso',
'settings.providers.page.quotaCredentials.usageToken': 'Token da API de uso',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Execute este comando no terminal e cole o token abaixo. Ele só pode ler o uso de créditos de LLM e expira após 30 dias.',
'settings.providers.page.quotaCredentials.refreshToken': 'Token de atualização',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Cole o token',
'settings.providers.page.openCodeGo.saveFailed': 'Não foi possível validar as credenciais do OpenCode Go.',
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
"gitView.commit.stageFilesHint": "Adicione arquivos ao stage para habilitar o commit.",
"gitView.commit.title": "Commit",
"gitView.common.cancel": "Cancelar",
'gitView.branch.switchBlockedNotice': 'Alterações sem commit — antes de trocar, será oferecido commit ou reversão.',
'gitView.branch.unpushedSingle': '1 commit sem push',
'gitView.branch.unpushedPlural': '{count} commits sem push',
'gitView.branch.recentBranches': 'Branches recentes',
'gitView.dirtySwitch.title': 'Alterações sem commit',
'gitView.dirtySwitch.descriptionSingle': 'A troca para {branch} foi pausada para não perder seu arquivo alterado. Faça commit ou reverta primeiro.',
'gitView.dirtySwitch.descriptionPlural': 'A troca para {branch} foi pausada para não perder seus {count} arquivos alterados. Faça commit ou reverta primeiro.',
'gitView.dirtySwitch.commitAndSwitch': 'Fazer commit e trocar',
'gitView.dirtySwitch.committedNotPushed': 'Commit feito em {branch}. O commit é apenas local — não foi enviado com push.',
'gitView.dirtySwitch.pushAfterCommit': 'Fazer push após o commit',
'gitView.dirtySwitch.pushFailed': 'Commit feito, mas o push falhou — a branch não foi trocada.',
'gitView.dirtySwitch.actionFailed': 'A ação falhou; a branch não foi trocada.',
'gitView.dirtySwitch.revertAndSwitch': 'Reverter e trocar',
'gitView.dirtySwitch.revertIncomplete': 'Algumas alterações não puderam ser revertidas, então a branch não foi trocada.',
"gitView.common.close": "Fechar",
"gitView.common.done": "Concluído",
"gitView.common.processing": "Procesando...",
@@ -1604,6 +1618,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': 'Sessão de revisão',
'chat.autoReview.actions.open': 'Abrir',
'chat.autoReview.actions.stop': 'Parar',
'chat.draftDirtyNotice.tooltip': 'Esta branch tem arquivos sem commit.\nA nova sessão os verá. Um commit ou um worktree os mantém separados.',
'chat.draftDirtyNotice.indicatorAria': 'Alterações sem commit neste diretório',
"diffView.hunk.label": "Trechos",
"diffView.hunk.stage": "Preparar",
"diffView.hunk.unstage": "Remover",
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Sil',
'settings.providers.page.quotaCredentials.saved': '{provider} kimlik bilgileri kaydedildi.',
'settings.providers.page.quotaCredentials.accessToken': 'Erişim token\'ı',
'settings.providers.page.quotaCredentials.usageToken': 'Kullanım API token\'ı',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Bu komutu terminalde çalıştırın, ardından token\'ı aşağıya yapıştırın. Yalnızca LLM kredi kullanımını okuyabilir ve 30 gün sonra sona erer.',
'settings.providers.page.quotaCredentials.refreshToken': 'Yenileme token\'ı',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Token\'ı yapıştır',
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go kimlik bilgileri doğrulanamadı.',
+20 -4
View File
@@ -777,6 +777,20 @@ export const dict = {
'gitView.commit.stageFilesHint': 'Commit\'i etkinleştirmek için dosyaları stage edin.',
'gitView.commit.title': 'Commit',
'gitView.common.cancel': 'İptal',
'gitView.branch.switchBlockedNotice': 'Commit edilmemiş değişiklikler var — geçişten önce commit veya geri alma adımı açılır.',
'gitView.branch.unpushedSingle': '1 commit push edilmedi',
'gitView.branch.unpushedPlural': '{count} commit push edilmedi',
'gitView.branch.recentBranches': 'Son kullanılan dallar',
'gitView.dirtySwitch.title': 'Commit edilmemiş değişiklikler',
'gitView.dirtySwitch.descriptionSingle': 'Değiştirilen dosyanız kaybolmasın diye {branch} dalına geçiş duraklatıldı. Önce commit edin veya geri alın.',
'gitView.dirtySwitch.descriptionPlural': 'Değiştirilen {count} dosyanız kaybolmasın diye {branch} dalına geçiş duraklatıldı. Önce commit edin veya geri alın.',
'gitView.dirtySwitch.commitAndSwitch': 'Commit et ve geç',
'gitView.dirtySwitch.committedNotPushed': '{branch} dalına commit edildi. Commit yalnızca yerel — push edilmedi.',
'gitView.dirtySwitch.pushAfterCommit': 'Commit sonrası push et',
'gitView.dirtySwitch.pushFailed': 'Commit edildi ancak push başarısız oldu — dal değiştirilmedi.',
'gitView.dirtySwitch.actionFailed': 'İşlem başarısız oldu; dal değiştirilmedi.',
'gitView.dirtySwitch.revertAndSwitch': 'Geri al ve geç',
'gitView.dirtySwitch.revertIncomplete': 'Bazı değişiklikler geri alınamadığı için dal değiştirilmedi.',
'gitView.common.close': 'Kapat',
'gitView.common.done': 'Tamam',
'gitView.common.processing': 'İşleniyor...',
@@ -796,10 +810,8 @@ export const dict = {
'gitView.conflict.resolveNewSession': 'Yeni session\'da çöz',
'gitView.empty.cleanDescription': 'Tüm değişiklikler commit edildi',
'gitView.empty.cleanTitle': 'Working tree temiz',
'gitView.empty.discoveringRepositories': 'Git repository\'leri aranıyor...',
'gitView.empty.discoverFailed': 'Git repository\'leri taranamadı',
'gitView.empty.retryDiscovery': 'Tekrar dene',
'gitView.empty.selectRepositoryPlaceholder': 'Bir repository seçin...',
'gitView.empty.discoveringRepositories': 'Git depoları aranıyor...',
'gitView.empty.discoverFailed': 'Git depoları taranamadı',
'gitView.empty.pullBehindPlural': '{count} commit pull et',
'gitView.empty.pullBehindSingle': '{count} commit pull et',
'gitView.header.identityTooltip': 'Git kimliği',
@@ -963,6 +975,8 @@ export const dict = {
'gitView.conflict.noDetailsAvailable': 'Çakışma detayları mevcut değil',
'gitView.empty.notGitRepository': 'Bu dizin bir Git repository\'si değil',
'gitView.empty.notGitRepositoryDescription': 'Bu dizinde Git\'i başlatın veya bir repository açın.',
'gitView.empty.retryDiscovery': 'Yeniden dene',
'gitView.empty.selectRepositoryPlaceholder': 'Bir repository seç...',
'gitView.empty.selectSessionOrDirectory': 'Git durumunu görüntülemek için bir session veya dizin seçin',
'gitView.empty.worktreeFeaturesUnavailable': 'Bu çalışma alanı modunda worktree özellikleri kullanılamıyor.',
'gitView.empty.worktreeSetupDescription': 'Worktree kurulumu tamamlanıyor ve repository durumu hazırlanıyor.',
@@ -1588,6 +1602,8 @@ export const dict = {
'chat.autoReview.reviewSessionLabel': 'İnceleme session\'ı',
'chat.autoReview.actions.open': 'Aç',
'chat.autoReview.actions.stop': 'Durdur',
'chat.draftDirtyNotice.tooltip': 'Bu dalda commit edilmemiş dosyalar var.\nYeni oturum onları görecek. Bir commit veya worktree onları ayrı tutar.',
'chat.draftDirtyNotice.indicatorAria': 'Bu dizinde commit edilmemiş değişiklikler var',
'diffView.hunk.label': 'Hunk\'lar',
'diffView.hunk.stage': 'Stage',
'diffView.hunk.unstage': 'Unstage',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Видалити',
'settings.providers.page.quotaCredentials.saved': 'Облікові дані {provider} збережено.',
'settings.providers.page.quotaCredentials.accessToken': 'Токен доступу',
'settings.providers.page.quotaCredentials.usageToken': 'Токен API використання',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Виконайте цю команду в терміналі, а потім вставте токен нижче. Він може лише читати використання LLM-кредитів і діє 30 днів.',
'settings.providers.page.quotaCredentials.refreshToken': 'Токен оновлення',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Вставте токен',
'settings.providers.page.openCodeGo.saveFailed': 'Не вдалося перевірити дані OpenCode Go.',
+16
View File
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
"gitView.commit.stageFilesHint": "Додайте файли до індексу, щоб увімкнути коміт.",
"gitView.commit.title": "Коміт",
"gitView.common.cancel": "Скасувати",
'gitView.branch.switchBlockedNotice': 'Є незакомічені зміни — перед перемиканням спершу буде крок «закомітити або скасувати».',
'gitView.branch.unpushedSingle': '1 незапушений коміт',
'gitView.branch.unpushedPlural': 'Незапушені коміти: {count}',
'gitView.branch.recentBranches': 'Нещодавні гілки',
'gitView.dirtySwitch.title': 'Незакомічені зміни',
'gitView.dirtySwitch.descriptionSingle': 'Перемикання на {branch} призупинено, щоб не втратити змінений файл. Спершу закоміть його або скасуй зміни.',
'gitView.dirtySwitch.descriptionPlural': 'Перемикання на {branch} призупинено, щоб не втратити {count} змінених файлів. Спершу закоміть їх або скасуй зміни.',
'gitView.dirtySwitch.commitAndSwitch': 'Закомітити й перемкнути',
'gitView.dirtySwitch.committedNotPushed': 'Закомічено в {branch}. Коміт лише локальний — його не запушено.',
'gitView.dirtySwitch.pushAfterCommit': 'Запушити після коміту',
'gitView.dirtySwitch.pushFailed': 'Закомічено, але push не вдався — гілку не перемкнено.',
'gitView.dirtySwitch.actionFailed': 'Дія не вдалася; гілку не перемкнено.',
'gitView.dirtySwitch.revertAndSwitch': 'Скасувати зміни й перемкнути',
'gitView.dirtySwitch.revertIncomplete': 'Частину змін не вдалося скасувати, тому гілку не перемкнено.',
"gitView.common.close": "Закрити",
"gitView.common.done": "Готово",
"gitView.common.processing": "Обробка...",
@@ -1604,6 +1618,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': 'Сесія ревʼю',
'chat.autoReview.actions.open': 'Відкрити',
'chat.autoReview.actions.stop': 'Зупинити',
'chat.draftDirtyNotice.tooltip': 'У цій гілці є незакомічені файли.\nНова сесія бачитиме їх. Коміт або worktree тримають їх окремо.',
'chat.draftDirtyNotice.indicatorAria': 'Незакомічені зміни в цьому каталозі',
"diffView.hunk.label": "Шматки",
"diffView.hunk.stage": "Додати",
"diffView.hunk.unstage": "Прибрати",
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': '删除',
'settings.providers.page.quotaCredentials.saved': '已保存 {provider} 凭据。',
'settings.providers.page.quotaCredentials.accessToken': '访问令牌',
'settings.providers.page.quotaCredentials.usageToken': '用量 API 令牌',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': '在终端中运行此命令,然后在下方粘贴令牌。该令牌只能读取 LLM 积分用量,并将在 30 天后过期。',
'settings.providers.page.quotaCredentials.refreshToken': '刷新令牌',
'settings.providers.page.quotaCredentials.tokenPlaceholder': '粘贴令牌',
'settings.providers.page.openCodeGo.saveFailed': '无法验证 OpenCode Go 凭据。',
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
'gitView.commit.stageFilesHint': '暂存文件以启用提交。',
'gitView.commit.title': '提交',
'gitView.common.cancel': '取消',
'gitView.branch.switchBlockedNotice': '有未提交的更改 — 切换前会先进入提交或还原步骤。',
'gitView.branch.unpushedSingle': '1 个未推送的提交',
'gitView.branch.unpushedPlural': '{count} 个未推送的提交',
'gitView.branch.recentBranches': '最近分支',
'gitView.dirtySwitch.title': '未提交的更改',
'gitView.dirtySwitch.descriptionSingle': '为避免丢失已更改的文件,切换到 {branch} 已暂停。请先提交或还原。',
'gitView.dirtySwitch.descriptionPlural': '为避免丢失 {count} 个已更改的文件,切换到 {branch} 已暂停。请先提交或还原。',
'gitView.dirtySwitch.commitAndSwitch': '提交并切换',
'gitView.dirtySwitch.committedNotPushed': '已提交到 {branch}。该提交仅在本地,尚未推送。',
'gitView.dirtySwitch.pushAfterCommit': '提交后推送',
'gitView.dirtySwitch.pushFailed': '已提交,但推送失败 — 未切换分支。',
'gitView.dirtySwitch.actionFailed': '操作失败,未切换分支。',
'gitView.dirtySwitch.revertAndSwitch': '还原并切换',
'gitView.dirtySwitch.revertIncomplete': '部分更改无法还原,因此未切换分支。',
'gitView.common.close': '关闭',
'gitView.common.done': '完成',
'gitView.common.processing': '处理中...',
@@ -1592,6 +1606,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': '审查会话',
'chat.autoReview.actions.open': '打开',
'chat.autoReview.actions.stop': '停止',
'chat.draftDirtyNotice.tooltip': '此分支有未提交的文件。\n新会话会看到它们。提交或工作树可将它们分开。',
'chat.draftDirtyNotice.indicatorAria': '此目录有未提交的更改',
'diffView.hunk.label': '代码块',
'diffView.hunk.stage': '暂存',
'diffView.hunk.unstage': '取消暂存',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': '刪除',
'settings.providers.page.quotaCredentials.saved': '已儲存 {provider} 憑證。',
'settings.providers.page.quotaCredentials.accessToken': '存取權杖',
'settings.providers.page.quotaCredentials.usageToken': '用量 API 權杖',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': '在終端機中執行此命令,然後在下方貼上權杖。該權杖只能讀取 LLM 點數用量,並將在 30 天後到期。',
'settings.providers.page.quotaCredentials.refreshToken': '重新整理權杖',
'settings.providers.page.quotaCredentials.tokenPlaceholder': '貼上權杖',
'settings.providers.page.openCodeGo.saveFailed': '無法驗證 OpenCode Go 憑證。',
@@ -809,6 +809,20 @@ export const dict: Record<I18nKey, string> = {
'gitView.commit.stageFilesHint': '暫存文件以啟用提交。',
'gitView.commit.title': '提交',
'gitView.common.cancel': '取消',
'gitView.branch.switchBlockedNotice': '有未提交的變更 — 切換前會先進入提交或還原步驟。',
'gitView.branch.unpushedSingle': '1 個未推送的提交',
'gitView.branch.unpushedPlural': '{count} 個未推送的提交',
'gitView.branch.recentBranches': '最近分支',
'gitView.dirtySwitch.title': '未提交的變更',
'gitView.dirtySwitch.descriptionSingle': '為避免遺失已變更的檔案,切換到 {branch} 已暫停。請先提交或還原。',
'gitView.dirtySwitch.descriptionPlural': '為避免遺失 {count} 個已變更的檔案,切換到 {branch} 已暫停。請先提交或還原。',
'gitView.dirtySwitch.commitAndSwitch': '提交並切換',
'gitView.dirtySwitch.committedNotPushed': '已提交到 {branch}。該提交僅在本地,尚未推送。',
'gitView.dirtySwitch.pushAfterCommit': '提交後推送',
'gitView.dirtySwitch.pushFailed': '已提交,但推送失敗 — 未切換分支。',
'gitView.dirtySwitch.actionFailed': '操作失敗,未切換分支。',
'gitView.dirtySwitch.revertAndSwitch': '還原並切換',
'gitView.dirtySwitch.revertIncomplete': '部分變更無法還原,因此未切換分支。',
'gitView.common.close': '關閉',
'gitView.common.done': '完成',
'gitView.common.processing': '處理中...',
@@ -1602,6 +1616,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': '審查工作階段',
'chat.autoReview.actions.open': '開啟',
'chat.autoReview.actions.stop': '停止',
'chat.draftDirtyNotice.tooltip': '此分支有未提交的檔案。\n新的工作階段會看到它們。提交或工作樹可將它們分開。',
'chat.draftDirtyNotice.indicatorAria': '此目錄有未提交的變更',
'diffView.hunk.label': '程式碼區塊',
'diffView.hunk.stage': '暫存',
'diffView.hunk.unstage': '取消暫存',
+31
View File
@@ -875,6 +875,7 @@ describe('updateDesktopSettings', () => {
expect(synced.length).toBeGreaterThan(0);
const bootstrapSync = synced.find((detail) => detail.bootstrap);
expect(bootstrapSync).toBeTruthy();
expect(bootstrapSync?.adoptTheme).toBe(true);
expect(bootstrapSync?.settings.useSystemTheme).toBe(undefined);
expect(bootstrapSync?.settings.lightThemeId).toBe(undefined);
expect(bootstrapSync?.settings.darkThemeId).toBe(undefined);
@@ -905,8 +906,38 @@ describe('updateDesktopSettings', () => {
expect(synced.length).toBeGreaterThan(0);
expect(synced.every((detail) => detail.bootstrap === false)).toBe(true);
expect(synced.every((detail) => detail.adoptTheme === false)).toBe(true);
expect(synced.every((detail) => detail.settings.themeVariant === 'dark')).toBe(true);
});
test('allows a bootstrap sync to preserve the current window theme', async () => {
getWindow();
invalidateSettingsCache();
registerSettingsApi(
async (changes) => ({ ...changes } as SettingsPayload),
async () => ({
settings: { activeProjectId: 'project-a', themeVariant: 'dark' },
source: 'web',
}),
);
const synced: SettingsSyncedDetail[] = [];
const listener = (event: Event): void => {
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail;
if (detail) synced.push(detail);
};
window.addEventListener('openchamber:settings-synced', listener);
try {
await syncDesktopSettings({ adoptTheme: false });
} finally {
window.removeEventListener('openchamber:settings-synced', listener);
}
const broadcastSync = synced.find((detail) => detail.bootstrap && !detail.adoptTheme);
expect(broadcastSync).toBeTruthy();
expect(broadcastSync?.settings.activeProjectId).toBe('project-a');
expect(broadcastSync?.settings.themeVariant).toBe('dark');
});
});
describe('unload lifecycle flush (#2197)', () => {
+9 -4
View File
@@ -205,14 +205,18 @@ export interface SettingsSyncedDetail {
not filtered; listeners gate their adoption on this flag and keep their
live state for the fields they own. */
bootstrap: boolean;
/** Whether this sync may replace this window's theme preferences. VS Code
settings broadcasts remain bootstrap-grade for shared workspace pointers,
but must not copy one webview's theme into another webview. */
adoptTheme: boolean;
}
const dispatchSettingsSynced = (settings: DesktopSettings, bootstrap: boolean): void => {
const dispatchSettingsSynced = (settings: DesktopSettings, bootstrap: boolean, adoptTheme = bootstrap): void => {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent<SettingsSyncedDetail>('openchamber:settings-synced', {
detail: { settings, bootstrap },
detail: { settings, bootstrap, adoptTheme },
}));
};
@@ -1900,8 +1904,9 @@ export const invalidateSettingsCache = (): void => {
_settingsCache = null;
};
export const syncDesktopSettings = async (options?: { bootstrap?: boolean }): Promise<void> => {
export const syncDesktopSettings = async (options?: { bootstrap?: boolean; adoptTheme?: boolean }): Promise<void> => {
const bootstrap = options?.bootstrap !== false;
const adoptTheme = options?.adoptTheme ?? bootstrap;
if (typeof window === 'undefined') {
return;
}
@@ -2030,7 +2035,7 @@ export const syncDesktopSettings = async (options?: { bootstrap?: boolean }): Pr
if (!isSettingsRuntimeContextCurrent(context)) return;
}
dispatchSettingsSynced(authoritativeSettings, bootstrap);
dispatchSettingsSynced(authoritativeSettings, bootstrap, adoptTheme);
};
try {
@@ -23,6 +23,7 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
{ id: 'opencode-go', name: 'OpenCode Go' },
{ id: 'crof', name: 'CrofAI' },
{ id: 'deepseek', name: 'DeepSeek' },
{ id: 'exe-dev', name: 'exe.dev' },
{ id: 'neuralwatt', name: 'NeuralWatt' },
{ id: 'xai', name: 'xAI' },
];
+1
View File
@@ -40,6 +40,7 @@ export async function summarizeSelectionForNotes(text: string, sessionId?: strin
body: JSON.stringify({
prompt: trimmed,
system: NOTES_SYSTEM_PROMPT,
sessionID: sessionId || undefined,
restrictToPreferredProvider: true,
...(preferredProviderID ? { preferredProviderID } : {}),
...(preferredModelID ? { preferredModelID } : {}),
+2 -2
View File
@@ -38,7 +38,7 @@ Examples:
- `useFeatureFlagsStore.ts`
- `useUpdateStore.ts`
These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted.
These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. The team filter is the one that is not a plain preference: a Linear team belongs to one workspace, and each OpenChamber instance has its own Linear login, so it is persisted per instance in `linearIssueListTeamIdByRuntime` and the flat `linearIssueListTeamId` is derived from it by `applyLinearIssueListFiltersForRuntime` — on an instance switch and when the rail mounts, since rehydration can run before the runtime endpoint is known. Carried across, a team id filters the new instance's list down to nothing. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted.
Context-panel session chats mount only the active chat iframe. After installing
its message listener, the iframe requests its authoritative visibility from the
@@ -84,7 +84,7 @@ Permission auto-accept policy is authoritative in the active Web server or VS Co
Shared safe storage treats durable failures per key. A quota or access failure creates an ephemeral override or tombstone for that key without disabling reads and writes for unrelated keys; later writes retry the durable backend. Deferred adapters retain failed operations for a later flush, and malformed Zustand JSON is removed and treated as missing so hydration can recover.
Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request. The unload flush uses `keepalive: true` on the HTTP write, because a plain fetch started from `pagehide`/`beforeunload` is cancelled with the document; `navigator.sendBeacon` is not used, as it cannot carry the runtime bearer header. On Capacitor neither `pagehide` nor `beforeunload` fires when the OS suspends the app, so the flush also runs on `App.appStateChange` going inactive.
Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty. Theme fields are the exception: only bootstrap-grade theme adoption applies fields supplied by the server, while omitted fields preserve this window's current runtime-scoped theme and settings save echoes never adopt a theme. VS Code settings broadcasts may still adopt shared workspace pointers without replacing each webview's editor-derived theme. Transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request. The unload flush uses `keepalive: true` on the HTTP write, because a plain fetch started from `pagehide`/`beforeunload` is cancelled with the document; `navigator.sendBeacon` is not used, as it cannot carry the runtime bearer header. On Capacitor neither `pagehide` nor `beforeunload` fires when the OS suspends the app, so the flush also runs on `App.appStateChange` going inactive.
Project ordering defaults to manual. Session display persistence v3 migrates the previously shipped `recent` project order to `manual` while preserving every other explicit sort mode.
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { McpStatus } from '@opencode-ai/sdk/v2';
import type { McpStatusMap } from './useMcpStore';
type Deferred<T> = { promise: Promise<T>; resolve: (value: T) => void };
const deferred = <T>(): Deferred<T> => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => { resolve = res; });
return { promise, resolve };
};
type McpStatusResult = Awaited<ReturnType<ReturnType<typeof opencodeModule.opencodeClient.getApiClient>['mcp']['status']>>;
let mcpStatusResponse: Deferred<McpStatusResult> = deferred();
const opencodeModule = await import('@/lib/opencode/client');
// Derived from the real client rather than spread from it: the client is a
// class instance, so a spread drops every prototype method the other modules
// loaded in this process call at import time.
// SAFETY: `Object.create` returns `any`; the object delegates to the real
// client for everything the two overrides below do not define.
const opencodeClientStub = Object.create(opencodeModule.opencodeClient) as typeof opencodeModule.opencodeClient;
// The SDK client is derived the same way, so only `mcp.status` is replaced and
// every other endpoint keeps its real implementation and type.
type McpApiClient = ReturnType<typeof opencodeModule.opencodeClient.getApiClient>;
const realApiClient = opencodeModule.opencodeClient.getApiClient();
const mcpApiStub: McpApiClient = Object.create(realApiClient, {
mcp: { value: { ...realApiClient.mcp, status: () => mcpStatusResponse.promise } },
});
opencodeClientStub.getApiClient = () => mcpApiStub;
opencodeClientStub.getScopedApiClient = () => mcpApiStub;
mock.module('@/lib/opencode/client', () => ({ ...opencodeModule, opencodeClient: opencodeClientStub }));
let skillsResponse: Deferred<Response> = deferred();
const runtimeFetchModule = await import('@/lib/runtime-fetch');
mock.module('@/lib/runtime-fetch', () => ({
...runtimeFetchModule,
runtimeFetch: () => skillsResponse.promise,
}));
const { useMcpStore } = await import('./useMcpStore');
const { useSkillsStore } = await import('./useSkillsStore');
const mcpStatusResult = (data: McpStatusMap): McpStatusResult => ({
data,
request: new Request('http://localhost/mcp'),
response: new Response(),
});
const connectedServer = (name: string): McpStatusMap => ({
// SAFETY: the store only reads `status` off each entry; the SDK type carries
// fields no consumer in this test path touches.
[name]: { status: 'connected' } as McpStatus,
});
describe('instance-scoped stores reject responses from the previous instance', () => {
beforeEach(() => {
mcpStatusResponse = deferred();
skillsResponse = deferred();
useMcpStore.getState().resetForRuntimeSwitch();
useSkillsStore.getState().resetForRuntimeSwitch();
});
test('an MCP status in flight during a switch does not land in the new instance', async () => {
const refresh = useMcpStore.getState().refresh({ directory: '/repo', silent: true });
useMcpStore.getState().resetForRuntimeSwitch();
mcpStatusResponse.resolve(mcpStatusResult(connectedServer('from-instance-a')));
await refresh;
expect(useMcpStore.getState().getStatusForDirectory('/repo')).toEqual({});
});
test('an MCP status that arrives with no switch is stored', async () => {
const refresh = useMcpStore.getState().refresh({ directory: '/repo', silent: true });
mcpStatusResponse.resolve(mcpStatusResult(connectedServer('server-a')));
await refresh;
expect(Object.keys(useMcpStore.getState().getStatusForDirectory('/repo'))).toEqual(['server-a']);
});
test('a skills load in flight during a switch does not land in the new instance', async () => {
const load = useSkillsStore.getState().loadSkills('/repo');
useSkillsStore.getState().resetForRuntimeSwitch();
skillsResponse.resolve(new Response(
JSON.stringify({ skills: [{ name: 'from-instance-a', path: '/repo/.agents/skills/a/SKILL.md' }] }),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
await load;
expect(useSkillsStore.getState().skillsByDirectory['/repo']).toBe(undefined);
});
});
@@ -13,6 +13,8 @@ type GitHubAuthStore = {
runtimeGitHub?: RuntimeAPIs['github'],
options?: { force?: boolean }
) => Promise<GitHubAuthStatusWithError | null>;
/** Same instance-scoping as Linear: the login lives on the connected instance. */
resetForRuntimeSwitch: () => void;
};
const fetchStatus = async (
@@ -36,6 +38,9 @@ const fetchStatus = async (
// In-flight dedup for refreshStatus
let _inFlightAuthRefresh: Promise<GitHubAuthStatusWithError | null> | null = null;
// Bumped by every reset so a response already in flight for the previous
// instance cannot write itself into the new instance's status.
let authGeneration = 0;
export const useGitHubAuthStore = create<GitHubAuthStore>((set, get) => ({
status: null,
@@ -50,13 +55,16 @@ export const useGitHubAuthStore = create<GitHubAuthStore>((set, get) => ({
if (_inFlightAuthRefresh) return _inFlightAuthRefresh;
const generation = authGeneration;
set({ isLoading: true });
_inFlightAuthRefresh = (async () => {
try {
const payload = await fetchStatus(runtimeGitHub);
if (generation !== authGeneration) return null;
set({ status: payload, isLoading: false, hasChecked: true });
return payload;
} catch (error) {
if (generation !== authGeneration) return null;
const message = error instanceof Error ? error.message : String(error);
set({
status: { connected: false, error: message },
@@ -69,4 +77,9 @@ export const useGitHubAuthStore = create<GitHubAuthStore>((set, get) => ({
return _inFlightAuthRefresh;
},
resetForRuntimeSwitch: () => {
authGeneration += 1;
_inFlightAuthRefresh = null;
set({ status: null, isLoading: false, hasChecked: false });
},
}));
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
import type { LinearAPI, LinearAuthStatus } from "@/lib/api/types"
mock.module("@/lib/runtime-fetch", () => ({ runtimeFetch: async () => new Response("{}") }))
const { useLinearAuthStore } = await import("./useLinearAuthStore")
const deferred = <T>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => { resolve = res })
return { promise, resolve }
}
// Only `authStatus` is exercised here; the rest of the surface is present so
// the stub is a real `LinearAPI` rather than an assertion over a fragment.
const unreachable = () => Promise.reject(new Error("not used in this test"))
const linearApi = (authStatus: LinearAPI["authStatus"]): LinearAPI => ({
authStatus,
authStart: unreachable,
authDisconnect: unreachable,
authActivate: unreachable,
issuesList: unreachable,
issueGet: unreachable,
issueStates: unreachable,
issueUpdate: unreachable,
mappingGet: unreachable,
mappingSet: unreachable,
sessionStatusPost: unreachable,
preferencesGet: unreachable,
preferencesSet: unreachable,
})
describe("Linear auth is scoped to the connected instance", () => {
beforeEach(() => {
useLinearAuthStore.getState().resetForRuntimeSwitch()
})
test("a switch drops the previous instance's login", async () => {
await useLinearAuthStore.getState().refreshStatus(
linearApi(async () => ({ connected: true })),
{ force: true },
)
expect(useLinearAuthStore.getState().status?.connected).toBe(true)
useLinearAuthStore.getState().resetForRuntimeSwitch()
expect(useLinearAuthStore.getState().status).toBeNull()
expect(useLinearAuthStore.getState().hasChecked).toBe(false)
})
test("a status still in flight for the previous instance cannot land in the new one", async () => {
const pending = deferred<LinearAuthStatus>()
const refresh = useLinearAuthStore.getState().refreshStatus(
linearApi(() => pending.promise),
{ force: true },
)
useLinearAuthStore.getState().resetForRuntimeSwitch()
pending.resolve({ connected: true })
await refresh
expect(useLinearAuthStore.getState().status).toBeNull()
expect(useLinearAuthStore.getState().hasChecked).toBe(false)
})
test("a failed check is not an authoritative disconnect", async () => {
await useLinearAuthStore.getState().refreshStatus(
linearApi(async () => ({ connected: true })),
{ force: true },
)
await useLinearAuthStore.getState().refreshStatus(
linearApi(async () => { throw new Error("offline") }),
{ force: true },
)
expect(useLinearAuthStore.getState().status?.connected).toBe(true)
expect(useLinearAuthStore.getState().status?.error).toBe("offline")
})
})
@@ -12,6 +12,13 @@ type LinearAuthStore = {
runtimeLinear?: RuntimeAPIs['linear'],
options?: { force?: boolean }
) => Promise<LinearAuthStatusWithError | null>;
/**
* Linear is authenticated on the OpenChamber instance, not in the browser, so
* this status belongs to whichever instance is connected. Switching instances
* must drop it otherwise the previous instance's login stays on screen and
* its issue surfaces remain usable against a runtime that has no Linear at all.
*/
resetForRuntimeSwitch: () => void;
};
const fetchStatus = async (
@@ -24,6 +31,9 @@ const fetchStatus = async (
};
let inFlightAuthRefresh: Promise<LinearAuthStatusWithError | null> | null = null;
// Bumped by every reset so a response already in flight for the previous
// instance cannot write itself into the new instance's status.
let authGeneration = 0;
export const useLinearAuthStore = create<LinearAuthStore>((set, get) => ({
status: null,
@@ -41,13 +51,16 @@ export const useLinearAuthStore = create<LinearAuthStore>((set, get) => ({
if (inFlightAuthRefresh) return inFlightAuthRefresh;
const generation = authGeneration;
set({ isLoading: true });
inFlightAuthRefresh = (async () => {
try {
const payload = await fetchStatus(runtimeLinear);
if (generation !== authGeneration) return null;
set({ status: payload, isLoading: false, hasChecked: true });
return payload;
} catch (error) {
if (generation !== authGeneration) return null;
const message = error instanceof Error ? error.message : String(error);
// A failed request is not an authoritative disconnect. Keep the last
// known status and leave `hasChecked` false so the next caller retries
@@ -64,4 +77,9 @@ export const useLinearAuthStore = create<LinearAuthStore>((set, get) => ({
return inFlightAuthRefresh;
},
resetForRuntimeSwitch: () => {
authGeneration += 1;
inFlightAuthRefresh = null;
set({ status: null, isLoading: false, hasChecked: false });
},
}));
+25
View File
@@ -54,6 +54,10 @@ type RefreshOptions = {
};
const ensureFreshInFlight = new Map<string, Promise<void>>();
// Bumped on every runtime switch. Status is keyed by directory alone and two
// instances can hold the same project path, so a request already in flight for
// the previous instance would otherwise write its servers over the new one's.
let mcpGeneration = 0;
type TestConnectionResult = {
status?: McpStatus;
@@ -91,6 +95,12 @@ interface McpStore {
completeAuth: (name: string, code: string, directory?: string | null) => Promise<void>;
clearAuth: (name: string, directory?: string | null) => Promise<void>;
testConnection: (name: string, directory?: string | null) => Promise<TestConnectionResult>;
/**
* MCP status is keyed by directory alone, and two instances can hold the same
* project path so on a switch the previous instance's servers would be
* reported for the new one. Drop everything and let consumers re-ask.
*/
resetForRuntimeSwitch: () => void;
}
export const useMcpStore = create<McpStore>()(
@@ -101,6 +111,18 @@ export const useMcpStore = create<McpStore>()(
lastErrorKeys: {},
refreshedAtKeys: {},
resetForRuntimeSwitch: () => {
mcpGeneration += 1;
ensureFreshInFlight.clear();
set({
byDirectory: {},
diagnosticsByDirectory: {},
loadingKeys: {},
lastErrorKeys: {},
refreshedAtKeys: {},
});
},
getStatusForDirectory: (directory) => {
const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory);
return get().byDirectory[key] ?? EMPTY_STATUS;
@@ -127,9 +149,11 @@ export const useMcpStore = create<McpStore>()(
}));
}
const generation = mcpGeneration;
try {
const api = getMcpApiClient(directory);
const result = await api.mcp.status();
if (generation !== mcpGeneration) return;
const data = (result.data ?? {}) as McpStatusMap;
set((state) => ({
@@ -145,6 +169,7 @@ export const useMcpStore = create<McpStore>()(
refreshedAtKeys: { ...state.refreshedAtKeys, [key]: Date.now() },
}));
} catch (error) {
if (generation !== mcpGeneration) return;
const message = error instanceof Error ? error.message : 'Failed to load MCP status';
set((state) => ({
loadingKeys: { ...state.loadingKeys, [key]: false },
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
import type { ProviderResult } from "@/types"
let runtimeKey = "url:https://instance-a"
let isInitialized = true
const fetched: string[] = []
type StubPayload = { usageDropdownProviders: string[] } | ProviderResult
let quotaRequestsFail = false;
const json = (body: StubPayload) => new Response(
JSON.stringify(body),
{ status: 200, headers: { "content-type": "application/json" } },
)
// Spread the real modules so the overrides stay a patch: `mock.module` is
// process-global, and a partial replacement would break every other module
// that imports something else from these files.
const runtimeSwitch = await import("@/lib/runtime-switch")
mock.module("@/lib/runtime-switch", () => ({ ...runtimeSwitch, getRuntimeKey: () => runtimeKey }))
const runtimeFetchModule = await import("@/lib/runtime-fetch")
mock.module("@/lib/runtime-fetch", () => ({
...runtimeFetchModule,
runtimeFetch: async (path: string) => {
fetched.push(path)
if (quotaRequestsFail) throw new Error("network down")
if (path.startsWith("/api/config/settings")) return json({ usageDropdownProviders: ["claude"] })
return json({ providerId: "claude", providerName: "Claude", ok: true, configured: true, usage: null, fetchedAt: 1 })
},
}))
const configStoreModule = await import("@/stores/useConfigStore")
mock.module("@/stores/useConfigStore", () => ({
...configStoreModule,
useConfigStore: { ...configStoreModule.useConfigStore, getState: () => ({ isInitialized }) },
}))
const { useQuotaStore } = await import("./useQuotaStore")
describe("Usage quotas are loaded once per ready instance", () => {
beforeEach(() => {
runtimeKey = "url:https://instance-a"
isInitialized = true
fetched.length = 0
quotaRequestsFail = false
useQuotaStore.getState().resetForRuntimeSwitch()
})
test("nothing is fetched while the instance has not reported itself initialised", async () => {
isInitialized = false
await useQuotaStore.getState().ensureLoadedForRuntime()
expect(fetched).toHaveLength(0)
expect(useQuotaStore.getState().loadedRuntimeKey).toBeNull()
// The instance finishes starting up: the same call now performs the load
// that a mount-time fetch would have answered "nothing configured".
isInitialized = true
await useQuotaStore.getState().ensureLoadedForRuntime()
expect(fetched.length).toBeGreaterThan(0)
expect(useQuotaStore.getState().results.length).toBeGreaterThan(0)
})
test("a second ask for the same instance does not refetch", async () => {
await useQuotaStore.getState().ensureLoadedForRuntime()
const afterFirst = fetched.length
await useQuotaStore.getState().ensureLoadedForRuntime()
expect(fetched.length).toBe(afterFirst)
})
test("a switch drops the previous instance's quotas and reloads for the new one", async () => {
await useQuotaStore.getState().ensureLoadedForRuntime()
expect(useQuotaStore.getState().results.length).toBeGreaterThan(0)
useQuotaStore.getState().resetForRuntimeSwitch()
expect(useQuotaStore.getState().results).toEqual([])
expect(useQuotaStore.getState().lastUpdated).toBeNull()
runtimeKey = "url:https://instance-b"
fetched.length = 0
await useQuotaStore.getState().ensureLoadedForRuntime()
expect(fetched.length).toBeGreaterThan(0)
expect(useQuotaStore.getState().loadedRuntimeKey).toBe("url:https://instance-b")
})
test("a quota still in flight for the previous instance cannot land in the new one", async () => {
const pending = useQuotaStore.getState().fetchProviderQuota("claude")
useQuotaStore.getState().resetForRuntimeSwitch()
await pending
expect(useQuotaStore.getState().results).toEqual([])
})
test("a transient runtime key loads nothing", async () => {
runtimeKey = "mobile-disconnected"
await useQuotaStore.getState().ensureLoadedForRuntime()
expect(fetched).toHaveLength(0)
})
test("a failed load is not recorded as loaded, so the next ask retries it", async () => {
quotaRequestsFail = true
await useQuotaStore.getState().ensureLoadedForRuntime()
expect(useQuotaStore.getState().loadedRuntimeKey).toBeNull()
quotaRequestsFail = false
fetched.length = 0
await useQuotaStore.getState().ensureLoadedForRuntime()
expect(fetched.length).toBeGreaterThan(0)
expect(useQuotaStore.getState().loadedRuntimeKey).toBe("url:https://instance-a")
})
test("concurrent asks share one load", async () => {
await Promise.all([
useQuotaStore.getState().ensureLoadedForRuntime(),
useQuotaStore.getState().ensureLoadedForRuntime(),
])
expect(fetched.filter((path) => path.startsWith("/api/quota/"))).toHaveLength(1)
})
test("a switch drops the previous instance's display settings", async () => {
await useQuotaStore.getState().ensureLoadedForRuntime()
expect(useQuotaStore.getState().dropdownProviderIds).toEqual(["claude"])
useQuotaStore.getState().setDisplayMode("remaining")
useQuotaStore.getState().resetForRuntimeSwitch()
// `dropdownProviderIds` decides which providers get queried, so carrying it
// over would ask the new instance through the old one's selection.
expect(useQuotaStore.getState().dropdownProviderIds.length).toBeGreaterThan(1)
expect(useQuotaStore.getState().displayMode).toBe("usage")
})
})
+98 -12
View File
@@ -8,8 +8,15 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getDefaultModels } from '@/lib/quota/model-families';
import { updateDesktopSettings } from '@/lib/persistence';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch';
import { useConfigStore } from '@/stores/useConfigStore';
const QUOTA_REFRESH_INTERVAL_MS = 3 * 60 * 1000;
// Quotas and their display settings are read from the connected OpenChamber
// instance, so both belong to that instance. Bumped on every reset so a
// response in flight for the previous instance cannot land in the new one.
let quotaGeneration = 0;
let inFlightRuntimeLoad: Promise<void> | null = null;
let quotaAutoRefreshConsumers = 0;
let quotaAutoRefreshInterval: number | null = null;
@@ -22,6 +29,8 @@ interface QuotaSettingsState {
interface QuotaStore extends QuotaSettingsState {
results: ProviderResult[];
/** Instance whose quotas `results` describes, or `null` when nothing is loaded. */
loadedRuntimeKey: string | null;
selectedProviderId: QuotaProviderId | null;
isLoading: boolean;
isFetchingProvider: Record<string, boolean>;
@@ -30,8 +39,10 @@ interface QuotaStore extends QuotaSettingsState {
loadSettings: () => Promise<void>;
fetchAllQuotas: () => Promise<void>;
fetchQuotas: (providerIds: QuotaProviderId[]) => Promise<void>;
fetchProviderQuota: (providerId: QuotaProviderId) => Promise<void>;
/** Resolves true when at least one provider answered — see `ensureLoadedForRuntime`. */
fetchQuotas: (providerIds: QuotaProviderId[]) => Promise<boolean>;
/** Resolves true when the instance answered, false on a transport failure. */
fetchProviderQuota: (providerId: QuotaProviderId) => Promise<boolean>;
setSelectedProvider: (providerId: QuotaProviderId | null) => void;
setDisplayMode: (mode: 'usage' | 'remaining') => void;
setDropdownProviderIds: (providerIds: QuotaProviderId[]) => void;
@@ -40,6 +51,18 @@ interface QuotaStore extends QuotaSettingsState {
setExpandedFamilies: (providerId: string, familyIds: string[]) => void;
toggleFamilyExpanded: (providerId: string, familyId: string) => void;
applyDefaultSelections: (providerId: string, availableModels: string[]) => void;
/**
* Load settings and quotas once per instance, when that instance is ready.
*
* Providers report themselves as configured only after the instance can read
* their credentials, which on a remote instance is not true the moment the UI
* mounts. A fetch fired at mount therefore answers "nothing configured", and
* because every provider then has a result, no consumer asks again until the
* three-minute refresh which is why Usage stayed missing from the
* work-status panel until Settings -> Usage forced a fresh fetch.
*/
ensureLoadedForRuntime: () => Promise<void>;
resetForRuntimeSwitch: () => void;
}
const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState => {
@@ -84,6 +107,13 @@ const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState
};
};
const defaultQuotaSettings = (): QuotaSettingsState => ({
displayMode: 'usage',
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
selectedModels: {},
expandedFamilies: {},
});
const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
@@ -107,18 +137,14 @@ const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
}
}
return {
displayMode: 'usage',
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
selectedModels: {},
expandedFamilies: {},
};
return defaultQuotaSettings();
};
export const useQuotaStore = create<QuotaStore>()(
devtools(
(set, get) => ({
results: [],
loadedRuntimeKey: null,
selectedProviderId: null,
isLoading: false,
isFetchingProvider: {},
@@ -130,8 +156,10 @@ export const useQuotaStore = create<QuotaStore>()(
expandedFamilies: {},
loadSettings: async () => {
const generation = quotaGeneration;
try {
const settings = await loadSettingsFromRuntime();
if (generation !== quotaGeneration) return;
set(settings);
} catch (error) {
console.warn('Failed to load usage settings:', error);
@@ -139,18 +167,23 @@ export const useQuotaStore = create<QuotaStore>()(
},
fetchQuotas: async (providerIds) => {
const generation = quotaGeneration;
set({ isLoading: true, error: null });
try {
await Promise.all(
const answered = await Promise.all(
providerIds.map((providerId) => get().fetchProviderQuota(providerId))
);
if (generation !== quotaGeneration) return false;
set({
isLoading: false,
lastUpdated: Date.now()
});
return answered.some(Boolean);
} catch (error) {
if (generation !== quotaGeneration) return false;
const message = error instanceof Error ? error.message : 'Failed to fetch quotas';
set({ isLoading: false, error: message });
return false;
}
},
@@ -159,6 +192,7 @@ export const useQuotaStore = create<QuotaStore>()(
},
fetchProviderQuota: async (providerId) => {
const generation = quotaGeneration;
set((state) => ({
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true }
}));
@@ -169,13 +203,16 @@ export const useQuotaStore = create<QuotaStore>()(
throw new Error(payload?.error || 'Failed to fetch quota');
}
if (generation !== quotaGeneration) return false;
const result = payload as ProviderResult;
set((state) => {
const next = state.results.filter((entry) => entry.providerId !== providerId);
next.push(result);
return { results: next, error: null };
});
return true;
} catch (error) {
if (generation !== quotaGeneration) return false;
const message = error instanceof Error ? error.message : 'Failed to fetch quota';
const fallback: ProviderResult = {
providerId,
@@ -191,13 +228,62 @@ export const useQuotaStore = create<QuotaStore>()(
next.push(fallback);
return { results: next, error: message };
});
return false;
} finally {
set((state) => ({
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false }
}));
if (generation === quotaGeneration) {
set((state) => ({
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false }
}));
}
}
},
ensureLoadedForRuntime: async () => {
const runtimeKey = getRuntimeKey();
if (isTransientRuntimeKey(runtimeKey)) return;
// Wait for the instance to report itself initialised. Asking earlier
// gets an honest-looking "not configured" for every provider, which is
// then cached as if it were the answer.
if (!useConfigStore.getState().isInitialized) return;
if (get().loadedRuntimeKey === runtimeKey) return;
if (inFlightRuntimeLoad) return inFlightRuntimeLoad;
const generation = quotaGeneration;
inFlightRuntimeLoad = (async () => {
await get().loadSettings();
if (generation !== quotaGeneration) return;
const { dropdownProviderIds, fetchQuotas } = get();
if (dropdownProviderIds.length === 0) return;
const answered = await fetchQuotas(dropdownProviderIds);
// Mark the instance loaded only once it actually answered. Claiming it
// up front meant a load that failed on a cold or briefly unreachable
// instance was never attempted again — Usage would stay empty until
// the three-minute refresh, or forever after a switch.
if (answered && generation === quotaGeneration) set({ loadedRuntimeKey: runtimeKey });
})().finally(() => { inFlightRuntimeLoad = null; });
return inFlightRuntimeLoad;
},
resetForRuntimeSwitch: () => {
quotaGeneration += 1;
inFlightRuntimeLoad = null;
set({
// Display mode, the provider selection and the per-provider model
// picks all come from the instance's own settings, and
// `dropdownProviderIds` decides what gets fetched — carrying them
// over would query the new instance through the old one's choices.
...defaultQuotaSettings(),
results: [],
loadedRuntimeKey: null,
selectedProviderId: null,
isLoading: false,
isFetchingProvider: {},
lastUpdated: null,
error: null,
});
},
setSelectedProvider: (providerId) => set({ selectedProviderId: providerId }),
setDisplayMode: (mode) => set({ displayMode: mode }),
setDropdownProviderIds: (providerIds) => set({ dropdownProviderIds: providerIds }),
+20
View File
@@ -173,6 +173,12 @@ interface SkillsStore {
renameSkill: (name: string, newName: string, directory?: string | null) => Promise<boolean>;
deleteSkill: (name: string, directory?: string | null) => Promise<boolean>;
getSkillByName: (name: string, directory?: string | null) => DiscoveredSkill | undefined;
/**
* Skills are discovered on the connected instance and cached by directory,
* which two instances can share so a switch must drop the caches rather
* than report the previous instance's skills for the new one.
*/
resetForRuntimeSwitch: () => void;
// Supporting files
readSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise<string | null>;
@@ -192,6 +198,10 @@ const SKILLS_LOAD_CACHE_TTL_MS = 5000;
const DEFAULT_SKILLS_CACHE_KEY = '__default__';
const skillsLastLoadedAt = new Map<string, number>();
const skillsLoadInFlight = new Map<string, Promise<boolean>>();
// Bumped on every runtime switch. Skills are discovered on the connected
// instance and cached by directory, which two instances can share, so a load
// already in flight for the previous instance must not write into the new one.
let skillsGeneration = 0;
const getSkillsCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
@@ -279,6 +289,13 @@ export const useSkillsStore = create<SkillsStore>()(
isLoading: false,
skillDraft: null,
resetForRuntimeSwitch: () => {
skillsGeneration += 1;
skillsLastLoadedAt.clear();
skillsLoadInFlight.clear();
set({ skills: [], skillsByDirectory: {}, isLoading: false });
},
setSelectedSkill: (name: string | null) => {
set({ selectedSkillName: name });
},
@@ -304,6 +321,7 @@ export const useSkillsStore = create<SkillsStore>()(
return inFlight;
}
const generation = skillsGeneration;
const request = (async () => {
set({ isLoading: true });
// Failure must never look like an empty project. The mirror is the
@@ -349,6 +367,7 @@ export const useSkillsStore = create<SkillsStore>()(
data.externalSkills ?? null,
);
if (generation !== skillsGeneration) return false;
set((state) => {
const next: Partial<SkillsStore> = {
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills },
@@ -367,6 +386,7 @@ export const useSkillsStore = create<SkillsStore>()(
}
console.error("Failed to load skills:", lastError);
if (generation !== skillsGeneration) return false;
set((state) => {
const next: Partial<SkillsStore> = {
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills },
@@ -1,12 +1,19 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { LINEAR_ISSUE_LIST_ALL_TEAMS, useUIStore } from './useUIStore';
import { beforeEach, describe, expect, mock, test } from 'bun:test';
let runtimeKey = 'url:https://instance-a';
const runtimeSwitch = await import('@/lib/runtime-switch');
mock.module('@/lib/runtime-switch', () => ({ ...runtimeSwitch, getRuntimeKey: () => runtimeKey }));
const { LINEAR_ISSUE_LIST_ALL_TEAMS, useUIStore } = await import('./useUIStore');
describe('linear issue list filters', () => {
beforeEach(() => {
runtimeKey = 'url:https://instance-a';
useUIStore.setState({
linearIssueListStatus: 'all',
linearIssueListAssignee: 'any',
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
linearIssueListTeamIdByRuntime: {},
linearIssueListPriority: 'all',
linearIssueFocus: null,
});
@@ -67,4 +74,43 @@ describe('linear issue list filters', () => {
useUIStore.getState().setLinearIssueFocus(null);
expect(useUIStore.getState().linearIssueFocus).toBeNull();
});
test('keeps the team filter with the instance that owns the workspace', () => {
useUIStore.getState().setLinearIssueListTeamId('team-eng');
expect(useUIStore.getState().linearIssueListTeamId).toBe('team-eng');
// Switching instances: a team belongs to one Linear workspace, so the new
// instance opens on all teams rather than on a filter matching nothing.
runtimeKey = 'url:https://instance-b';
useUIStore.getState().applyLinearIssueListFiltersForRuntime();
expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS);
useUIStore.getState().setLinearIssueListTeamId('team-ops');
expect(useUIStore.getState().linearIssueListTeamId).toBe('team-ops');
// Switching back restores the first instance's own choice.
runtimeKey = 'url:https://instance-a';
useUIStore.getState().applyLinearIssueListFiltersForRuntime();
expect(useUIStore.getState().linearIssueListTeamId).toBe('team-eng');
});
test('a transient runtime key stores nothing and reads as all teams', () => {
runtimeKey = 'mobile-disconnected';
useUIStore.getState().setLinearIssueListTeamId('team-eng');
expect(useUIStore.getState().linearIssueListTeamIdByRuntime).toEqual({});
useUIStore.getState().applyLinearIssueListFiltersForRuntime();
expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS);
});
test('resetting filters clears the stored team for this instance only', () => {
useUIStore.getState().setLinearIssueListTeamId('team-eng');
runtimeKey = 'url:https://instance-b';
useUIStore.getState().setLinearIssueListTeamId('team-ops');
useUIStore.getState().resetLinearIssueListFilters();
expect(useUIStore.getState().linearIssueListTeamIdByRuntime).toEqual({ 'url:https://instance-a': 'team-eng' });
});
});
+72 -6
View File
@@ -12,6 +12,7 @@ import type { ProjectRef } from '@/lib/projectContextApi';
import { useFilesViewTabsStore } from './useFilesViewTabsStore';
import { isWindowsArm64 } from '@/lib/platform';
import { isVSCodeRuntime } from '@/lib/desktop';
import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch';
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch';
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal';
@@ -65,6 +66,38 @@ function sanitizeLinearIssueListTeamId(value: unknown): string {
return teamId || LINEAR_ISSUE_LIST_ALL_TEAMS;
}
/**
* Store the team filter under the connected instance, dropping the entry when
* it falls back to all teams so the map does not accumulate defaults. Transient
* keys (uninitialised, mobile-disconnected) name no instance and are not written.
*/
function writeLinearTeamIdForRuntime(
entries: Record<string, string>,
teamId: string,
): Record<string, string> {
const runtimeKey = getRuntimeKey();
if (isTransientRuntimeKey(runtimeKey)) return entries;
const next = { ...entries };
if (teamId === LINEAR_ISSUE_LIST_ALL_TEAMS) {
delete next[runtimeKey];
} else {
next[runtimeKey] = teamId;
}
return next;
}
function sanitizeLinearIssueListTeamIdByRuntime(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const entries: Record<string, string> = {};
// SAFETY: guarded above as a non-array object; every value is re-checked below.
for (const [runtimeKey, teamId] of Object.entries(value as Record<string, unknown>)) {
if (!runtimeKey.trim() || typeof teamId !== 'string') continue;
const sanitized = sanitizeLinearIssueListTeamId(teamId);
if (sanitized !== LINEAR_ISSUE_LIST_ALL_TEAMS) entries[runtimeKey] = sanitized;
}
return entries;
}
function sanitizeLinearIssueListPriority(value: unknown): LinearIssueListPriority {
return value === 'none' || value === 'urgent' || value === 'high' || value === 'medium' || value === 'low' || value === 'all'
? value
@@ -820,7 +853,16 @@ interface UIStore {
gitChangesViewMode: 'flat' | 'tree';
linearIssueListStatus: LinearIssueListStatus;
linearIssueListAssignee: LinearIssueListAssignee;
/**
* Team filter for the instance currently connected. A Linear team belongs to
* one workspace, and each OpenChamber instance has its own Linear login, so
* this is derived from `linearIssueListTeamIdByRuntime` rather than persisted
* on its own a team id carried across a switch filters the new instance's
* list down to nothing.
*/
linearIssueListTeamId: string;
/** Team filter per instance, keyed the same way every runtime-scoped cache is. */
linearIssueListTeamIdByRuntime: Record<string, string>;
linearIssueListPriority: LinearIssueListPriority;
/** One-shot identifier for opening a Linear issue in the rail panel. Not persisted. */
linearIssueFocus: string | null;
@@ -1023,6 +1065,8 @@ interface UIStore {
setLinearIssueListStatus: (status: LinearIssueListStatus) => void;
setLinearIssueListAssignee: (assignee: LinearIssueListAssignee) => void;
setLinearIssueListTeamId: (teamId: string) => void;
/** Re-read the team filter for the instance now connected. */
applyLinearIssueListFiltersForRuntime: () => void;
setLinearIssueListPriority: (priority: LinearIssueListPriority) => void;
resetLinearIssueListFilters: () => void;
setLinearIssueFocus: (identifier: string | null) => void;
@@ -1186,6 +1230,7 @@ export const useUIStore = create<UIStore>()(
linearIssueListStatus: 'all',
linearIssueListAssignee: 'any',
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
linearIssueListTeamIdByRuntime: {},
linearIssueListPriority: 'all',
linearIssueFocus: null,
isTimelineDialogOpen: false,
@@ -2113,7 +2158,20 @@ export const useUIStore = create<UIStore>()(
},
setLinearIssueListTeamId: (teamId) => {
set({ linearIssueListTeamId: sanitizeLinearIssueListTeamId(teamId) });
const sanitized = sanitizeLinearIssueListTeamId(teamId);
set((state) => ({
linearIssueListTeamId: sanitized,
linearIssueListTeamIdByRuntime: writeLinearTeamIdForRuntime(state.linearIssueListTeamIdByRuntime, sanitized),
}));
},
applyLinearIssueListFiltersForRuntime: () => {
const runtimeKey = getRuntimeKey();
set((state) => ({
linearIssueListTeamId: isTransientRuntimeKey(runtimeKey)
? LINEAR_ISSUE_LIST_ALL_TEAMS
: state.linearIssueListTeamIdByRuntime[runtimeKey] ?? LINEAR_ISSUE_LIST_ALL_TEAMS,
}));
},
setLinearIssueListPriority: (priority) => {
@@ -2121,12 +2179,16 @@ export const useUIStore = create<UIStore>()(
},
resetLinearIssueListFilters: () => {
set({
set((state) => ({
linearIssueListStatus: 'all',
linearIssueListAssignee: 'any',
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
linearIssueListTeamIdByRuntime: writeLinearTeamIdForRuntime(
state.linearIssueListTeamIdByRuntime,
LINEAR_ISSUE_LIST_ALL_TEAMS,
),
linearIssueListPriority: 'all',
});
}));
},
setLinearIssueFocus: (identifier) => {
@@ -2581,7 +2643,7 @@ export const useUIStore = create<UIStore>()(
{
name: 'ui-store',
storage: createDeferredSafeJSONStorage(),
version: 18,
version: 19,
migrate: (persistedState, version) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState;
@@ -2792,7 +2854,11 @@ export const useUIStore = create<UIStore>()(
state.linearIssueListStatus = sanitizeLinearIssueListStatus(state.linearIssueListStatus);
state.linearIssueListAssignee = sanitizeLinearIssueListAssignee(state.linearIssueListAssignee);
state.linearIssueListTeamId = sanitizeLinearIssueListTeamId(state.linearIssueListTeamId);
// v18 -> v19: the team filter became per instance. The legacy flat
// value names a team in one workspace with nothing to say which
// instance it came from, so it is dropped rather than guessed at.
delete state.linearIssueListTeamId;
state.linearIssueListTeamIdByRuntime = sanitizeLinearIssueListTeamIdByRuntime(state.linearIssueListTeamIdByRuntime);
state.linearIssueListPriority = sanitizeLinearIssueListPriority(state.linearIssueListPriority);
state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap);
@@ -2874,7 +2940,7 @@ export const useUIStore = create<UIStore>()(
gitChangesViewMode: state.gitChangesViewMode,
linearIssueListStatus: state.linearIssueListStatus,
linearIssueListAssignee: state.linearIssueListAssignee,
linearIssueListTeamId: state.linearIssueListTeamId,
linearIssueListTeamIdByRuntime: state.linearIssueListTeamIdByRuntime,
linearIssueListPriority: state.linearIssueListPriority,
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
notificationMode: state.notificationMode,
@@ -186,7 +186,11 @@ mock.module("../selection-store", () => ({
},
}))
// Spread the real module so the stub stays a patch: anything else importing
// runtime-switch in this process still gets its remaining exports.
const runtimeSwitchModule = await import("@/lib/runtime-switch")
mock.module("@/lib/runtime-switch", () => ({
...runtimeSwitchModule,
getRuntimeApiBaseUrl: () => "",
getRuntimeKey: () => "test-runtime",
initializeRuntimeEndpoint: () => undefined,
+1
View File
@@ -18,6 +18,7 @@ export type QuotaProviderId =
| 'opencode-go'
| 'crof'
| 'deepseek'
| 'exe-dev'
| 'neuralwatt'
| 'xai';
+1
View File
@@ -1,6 +1,7 @@
/// <reference types="vite/client" />
interface Window {
__openchamberEnsureNerdFonts?: () => Promise<void>;
__opencodeDebug?: {
getLastAssistantMessage: () => unknown;
getAllMessages: (truncate?: boolean) => unknown[];