fix(ui): preserve VS Code themes during settings broadcasts
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,');
|
||||
});
|
||||
});
|
||||
@@ -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)];
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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,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`;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 d’accès',
|
||||
'settings.providers.page.quotaCredentials.usageToken': 'Jeton API d’utilisation',
|
||||
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Exécutez cette commande dans votre terminal, puis collez le jeton ci-dessous. Il peut uniquement lire l’utilisation des crédits LLM et expire après 30 jours.',
|
||||
'settings.providers.page.quotaCredentials.refreshToken': 'Jeton d’actualisation',
|
||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Collez le jeton',
|
||||
'settings.providers.page.openCodeGo.saveFailed': 'Impossible de valider les identifiants OpenCode Go.',
|
||||
|
||||
@@ -618,6 +618,20 @@ export const dict = {
|
||||
'gitView.commit.stageFilesHint': 'Ajoutez des fichiers à l’index pour activer le commit.',
|
||||
'gitView.commit.title': 'Commettre',
|
||||
'gitView.common.cancel': 'Annuler',
|
||||
'gitView.branch.switchBlockedNotice': 'Modifications non commitées — le changement passe d’abord 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 d’abord.',
|
||||
'gitView.dirtySwitch.descriptionPlural': 'Le passage à {branch} est suspendu pour ne pas perdre vos {count} fichiers modifiés. Commitez-les ou annulez-les d’abord.',
|
||||
'gitView.dirtySwitch.commitAndSwitch': 'Commiter et changer',
|
||||
'gitView.dirtySwitch.committedNotPushed': 'Commité sur {branch}. Le commit est local uniquement — il n’a pas été poussé.',
|
||||
'gitView.dirtySwitch.pushAfterCommit': 'Pousser après le commit',
|
||||
'gitView.dirtySwitch.pushFailed': 'Commité, mais le push a échoué — la branche n’a pas été changée.',
|
||||
'gitView.dirtySwitch.actionFailed': 'L’action a échoué ; la branche n’a pas été changée.',
|
||||
'gitView.dirtySwitch.revertAndSwitch': 'Annuler et changer',
|
||||
'gitView.dirtySwitch.revertIncomplete': 'Certaines modifications n’ont pas pu être annulées, la branche n’a 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 の認証情報を検証できませんでした。',
|
||||
|
||||
@@ -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 인증 정보를 검증할 수 없습니다.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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ı.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': '取消暫存',
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,6 +18,7 @@ export type QuotaProviderId =
|
||||
| 'opencode-go'
|
||||
| 'crof'
|
||||
| 'deepseek'
|
||||
| 'exe-dev'
|
||||
| 'neuralwatt'
|
||||
| 'xai';
|
||||
|
||||
|
||||
Vendored
+1
@@ -1,6 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
__openchamberEnsureNerdFonts?: () => Promise<void>;
|
||||
__opencodeDebug?: {
|
||||
getLastAssistantMessage: () => unknown;
|
||||
getAllMessages: (truncate?: boolean) => unknown[];
|
||||
|
||||
Reference in New Issue
Block a user