From bec7a825685b8431152202b7e1ade9f73de1f3c2 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 1 Sep 2026 10:51:08 +0300 Subject: [PATCH 01/17] fix: reserve space for project action buttons Prevents the project label area from shifting when actions appear Matches spacing behavior with the collapse-toggle branch Keeps hover and focus states aligned --- .../session/sidebar/projects/sortableItems.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx b/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx index 303c8470..1db05b32 100644 --- a/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx +++ b/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx @@ -285,7 +285,14 @@ export const SortableProjectItem: React.FC = ({ {showBranchSelector ? ( - + + + + + {hasUncommittedChanges ? ( + + {t('chat.draftDirtyNotice.tooltip')} + + ) : null} + ) : null} ); diff --git a/packages/ui/src/components/ui/tooltip.tsx b/packages/ui/src/components/ui/tooltip.tsx index ff4188ff..04d718ed 100644 --- a/packages/ui/src/components/ui/tooltip.tsx +++ b/packages/ui/src/components/ui/tooltip.tsx @@ -256,6 +256,7 @@ type ContentProps = React.ComponentProps & { 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} - + {showArrow ? ( + + ) : null} diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 5b49a811..7ff07e05 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -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 = ({ 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(null); const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge'); const [stashDialogBranch, setStashDialogBranch] = React.useState(''); @@ -1371,6 +1374,21 @@ export const GitView: React.FC = ({ 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 = ({ isActive }) => { return (
- = ({ isActive }) => { /> )} + { 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); + }} + /> + | undefined; + currentBranchAhead?: number; onCheckout: (branch: string) => void; onCreate: (name: string, remote?: GitRemote) => Promise; 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 = ({ 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(() => getRecentBranches(directory)); + const [unpushedCounts, setUnpushedCounts] = React.useState>({}); const createInputRef = React.useRef(null); const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent) => { @@ -94,6 +112,7 @@ export const BranchSelector: React.FC = ({ setIsOpen(false); return; } + setRecentBranches(rememberRecentBranch(directory, branch)); onCheckout(branch); setIsOpen(false); setSearch(''); @@ -158,6 +177,109 @@ export const BranchSelector: React.FC = ({ } }, [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 ( + + ); + }; + + return ( + <> + + + setIsOpen(false)} + > +
+ 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 ? ( +
+
+ ) : null} + {recentLocalBranches.length > 0 ? ( +
+

{t('gitView.branch.recentBranches')}

+ {recentLocalBranches.map((branch) => renderBranch(branch))} +
+ ) : null} +
+

{t('gitView.branch.localBranches')}

+ {filteredLocal.map((branch) => renderBranch(branch))} +
+
+

{t('gitView.branch.remoteBranches')}

+ {filteredRemote.map((branch) => renderBranch(branch, true))} +
+
+
+ + ); + } + return ( @@ -192,6 +314,12 @@ export const BranchSelector: React.FC = ({ onValueChange={setSearch} onKeyDown={stopDropdownTypeahead} /> + {switchBlockedNotice ? ( +
+
+ ) : null} = ({ + {recentBranches.filter((branch) => localBranches.includes(branch)).length > 0 ? ( + <> + + {recentBranches.filter((branch) => localBranches.includes(branch)).map((branch) => ( + handleCheckout(branch)}> + + {branch} + {(() => { + 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 ? ( + + + ) : null; + })()} + + {currentBranch === branch ? {t('gitView.branch.currentBadge')} : null} + + ))} + + + + ) : null} + {filteredLocal.map((branch) => ( 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; + /** Produce an AI commit message for the current changes, same as the commit panel. */ + onGenerateMessage: () => Promise; + /** Revert every uncommitted change, then perform the checkout. */ + onRevertAndSwitch: () => Promise; +} + +/** + * 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 = ({ + 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 ( + { if (!isProcessing) onOpenChange(next); }}> + +
+ +
+ + {t('gitView.dirtySwitch.title')} +
+ + {changedFileCount === 1 + ? t('gitView.dirtySwitch.descriptionSingle', { branch: targetBranch }) + : t('gitView.dirtySwitch.descriptionPlural', { branch: targetBranch, count: changedFileCount })} + +
+ +
+ 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" + /> + +
+ +
+ + !isProcessing && setPushAfter(!pushAfter)} + > + {t('gitView.dirtySwitch.pushAfterCommit')} + +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index 8324f7a3..bbcaa6ef 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -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 = ({ }; export const GitHeader: React.FC = ({ + directory, status, localBranches, remoteBranches, @@ -272,6 +275,7 @@ export const GitHeader: React.FC = ({ repositoryRoot, }) => { const { t } = useI18n(); + const { isMobile } = useDeviceInfo(); if (!status) { return null; } @@ -425,20 +429,23 @@ export const GitHeader: React.FC = ({
- {isWorktreeMode ? ( + {isWorktreeMode && !isMobile ? ( ) : ( 0 ? t('gitView.branch.switchBlockedNotice') : null} /> )} {repositoryOptionsForPicker.length > 0 && onSelectRepository ? ( diff --git a/packages/ui/src/components/views/git/recentBranches.test.ts b/packages/ui/src/components/views/git/recentBranches.test.ts new file mode 100644 index 00000000..7d7278af --- /dev/null +++ b/packages/ui/src/components/views/git/recentBranches.test.ts @@ -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(); + + 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', + ]); + }); +}); diff --git a/packages/ui/src/components/views/git/recentBranches.ts b/packages/ui/src/components/views/git/recentBranches.ts new file mode 100644 index 00000000..ccf0f33c --- /dev/null +++ b/packages/ui/src/components/views/git/recentBranches.ts @@ -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; + +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; +}; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index d1b70aa4..910896cb 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -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; +} + export interface GitDiffResponse { diff: string; } @@ -505,6 +510,7 @@ export interface GitAPI { revertGitHunk?(directory: string, filePath: string, patch: string): Promise; isLinkedWorktree(directory: string): Promise; getGitBranches(directory: string): Promise; + getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise; deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>; deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>; removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }>; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 5c692d8b..1d0e325a 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -214,6 +214,12 @@ export async function getGitBranches(directory: string): Promise { + 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)); diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index d9908dfc..197eb7cf 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -7,6 +7,7 @@ import type { GitFileDiffResponse, GetGitFileDiffOptions, GitBranch, + GitUnpushedBranchCounts, GitDeleteBranchPayload, GitDeleteRemoteBranchPayload, GitRemoveRemotePayload, @@ -493,6 +494,16 @@ export async function getGitBranches(directory: string): Promise { return response.json(); } +export async function getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise { + 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'); diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 761aba6a..a9a512d9 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 9a3aae3a..c6a82a67 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 967ad2da..2804b35a 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -796,6 +796,20 @@ export const dict: Record = { "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 = { '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", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index e5b82ed5..cb58b62d 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 1e41c450..56b6b27b 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -793,6 +793,20 @@ export const dict: Record = { '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 = { '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': 'コンテキスト、リマインダー、リンクを記録', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index bb1c1c0a..96781aac 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -796,6 +796,20 @@ export const dict: Record = { '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 = { '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': '스테이지 해제', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 6209b2f0..77b3864c 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1844,6 +1844,8 @@ export const dict: Record = { '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 = { '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...', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 4c0425f2..18d7bd12 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -796,6 +796,20 @@ export const dict: Record = { "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 = { '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", diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 593255c9..12448fe6 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -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,6 +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 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', @@ -959,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.', @@ -1584,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', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 48232f2b..ce612215 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -796,6 +796,20 @@ export const dict: Record = { "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 = { '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": "Прибрати", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 8806c72f..ecb8f316 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -796,6 +796,20 @@ export const dict: Record = { '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 = { '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': '取消暂存', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index e56ee9d0..1dc94f20 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -809,6 +809,20 @@ export const dict: Record = { '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 = { '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': '取消暫存', diff --git a/packages/vscode/src/bridge-git-runtime.ts b/packages/vscode/src/bridge-git-runtime.ts index 5abb4217..8fdbeb5e 100644 --- a/packages/vscode/src/bridge-git-runtime.ts +++ b/packages/vscode/src/bridge-git-runtime.ts @@ -83,6 +83,16 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } + case 'api:git/branch-push-status': { + const { directory, branches } = (payload || {}) as { directory?: string; branches?: string[] }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + if (!Array.isArray(branches) || branches.some((branch) => typeof branch !== 'string')) { + return { id, type, success: false, error: 'branches must be an array of branch names' }; + } + return { id, type, success: true, data: await gitService.getGitUnpushedBranchCounts(directory!, branches) }; + } + case 'api:git/remote-branches': { const { directory, branch, remote } = (payload || {}) as { directory?: string; diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 7d0abcff..c4141bcc 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -671,6 +671,23 @@ export interface GitBranchResult { branches: Record; } +export async function getGitUnpushedBranchCounts(directory: string, requestedBranches: string[]): Promise<{ counts: Record }> { + const requested = [...new Set(requestedBranches)].filter(Boolean).slice(0, 5); + if (requested.length === 0) return { counts: {} }; + const local = new Set((await getGitBranchesRaw(directory)).all.filter((branch) => !branch.startsWith('remotes/'))); + const counts: Record = {}; + await Promise.all(requested.map(async (branch) => { + if (!local.has(branch)) return; + const upstreamResult = await execGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branch}@{upstream}`], directory); + const upstream = upstreamResult.exitCode === 0 ? upstreamResult.stdout.trim() : ''; + if (!upstream) return; + const countResult = await execGit(['rev-list', '--count', `${upstream}..${branch}`], directory); + const count = countResult.exitCode === 0 ? Number.parseInt(countResult.stdout.trim(), 10) : 0; + if (Number.isFinite(count) && count > 0) counts[branch] = count; + })); + return { counts }; +} + /** * Get all branches for a directory */ diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index bcfd23cb..7ed75e21 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -130,6 +130,10 @@ export const createVSCodeGitAPI = (): GitAPI => ({ return sendBridgeMessage('api:git/branches', { directory, method: 'GET' }); }, + getGitUnpushedBranchCounts: async (directory: string, branches: string[]) => { + return sendBridgeMessage('api:git/branch-push-status', { directory, branches }); + }, + deleteGitBranch: async (directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> => { return sendBridgeMessage<{ success: boolean }>('api:git/branches', { directory, diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 211ab57e..f328dbb3 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -39,6 +39,7 @@ The following functions are exported and used by the web server: ### Branch Operations - `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches). +- `getUnpushedBranchCounts(directory, branchNames)`: Count commits ahead of each locally known upstream for up to five supplied local branches. This reads local refs only and omits branches without an upstream. - `createBranch(directory, branchName, options)`: Create and checkout a new branch. - `checkoutBranch(directory, branchName)`: Checkout an existing branch. A remote-tracking name (`origin/main`, or the `remotes/`-prefixed form) resolves to the local branch of that name, created with `--track` when it does not exist yet, because the branch selector offers remote branches as places to work rather than commits to inspect — a literal checkout of the remote ref would detach HEAD. A local branch whose own name looks like a remote ref wins over that resolution, and anything unresolvable is checked out as requested. The returned `branch` is the branch that was actually checked out, which callers should report instead of the requested name. - `deleteBranch(directory, branch, options)`: Delete a branch (supports force flag). diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index 0fe38cdf..b04c48d9 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -873,6 +873,22 @@ export function registerGitRoutes(app) { } }); + app.post('/api/git/branch-push-status', async (req, res) => { + const { getUnpushedBranchCounts } = await getGitLibraries(); + try { + const directory = req.query.directory; + const branches = req.body?.branches; + if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); + if (!Array.isArray(branches) || branches.some((branch) => typeof branch !== 'string')) { + return res.status(400).json({ error: 'branches must be an array of branch names' }); + } + res.json(await getUnpushedBranchCounts(directory, branches)); + } catch (error) { + console.error('Failed to get branch push status:', error); + res.status(500).json({ error: error.message || 'Failed to get branch push status' }); + } + }); + app.post('/api/git/branches', async (req, res) => { const { createBranch } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index c103d3fa..76daf5af 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -3687,6 +3687,35 @@ export async function getBranches(directory) { } } +/** + * Counts locally unpushed commits for a small caller-supplied set of local + * branches. This deliberately reads only local refs: the branch picker calls + * it when opened, never polls, and never fetches a remote behind the user's + * back. Unknown, remote, and upstream-less branches are omitted. + */ +export async function getUnpushedBranchCounts(directory, branchNames) { + const { git } = await createRepositoryGitContext(directory); + const requested = [...new Set(Array.isArray(branchNames) ? branchNames : [])] + .filter((name) => typeof name === 'string' && name.length > 0) + .slice(0, 5); + if (requested.length === 0) return { counts: {} }; + + const local = new Set((await git.branchLocal()).all); + const counts = {}; + await Promise.all(requested.map(async (branch) => { + if (!local.has(branch)) return; + const upstream = await git.raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branch}@{upstream}`]) + .then((value) => value.trim()) + .catch(() => ''); + if (!upstream) return; + const count = await git.raw(['rev-list', '--count', `${upstream}..${branch}`]) + .then((value) => Number.parseInt(value.trim(), 10)) + .catch(() => 0); + if (Number.isFinite(count) && count > 0) counts[branch] = count; + })); + return { counts }; +} + async function getRemoteDefaultBranches(git) { let defaults = {}; diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 9aa84944..61112684 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -12,6 +12,7 @@ import { createWorktree, getWorktreeBootstrapStatus, getBranches, + getUnpushedBranchCounts, getRangeDiff, getStatus, getWorktrees, @@ -1498,6 +1499,21 @@ describe.runIf(canRunGit())('getBranches', () => { }); }); +describe.runIf(canRunGit())('getUnpushedBranchCounts', () => { + it('counts only commits ahead of a locally known upstream', async () => { + const { repository } = createRepositoryWithRemote(); + runGit(repository, ['branch', '--set-upstream-to=origin/react', 'next']); + fs.writeFileSync(path.join(repository, 'ahead.txt'), 'ahead\n'); + runGit(repository, ['add', 'ahead.txt']); + runGit(repository, ['commit', '-m', 'ahead']); + runGit(repository, ['checkout', '-b', 'no-upstream']); + + await expect(getUnpushedBranchCounts(repository, ['next', 'no-upstream', 'remotes/origin/react'])).resolves.toEqual({ + counts: { next: 1 }, + }); + }); +}); + describe.runIf(canRunGit())('getRangeDiff', () => { it('resolves a base that exists only on a remote other than origin', async () => { const { repository } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'react' }); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index d6bc9fd9..387a0871 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -23,6 +23,7 @@ export const createWebGitAPI = (): GitAPI => ({ revertGitHunk: gitApiHttp.revertGitHunk, isLinkedWorktree: gitApiHttp.isLinkedWorktree, getGitBranches: gitApiHttp.getGitBranches, + getGitUnpushedBranchCounts: gitApiHttp.getGitUnpushedBranchCounts, deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'], deleteRemoteBranch: gitApiHttp.deleteRemoteBranch as GitAPI['deleteRemoteBranch'], removeRemote: gitApiHttp.removeRemote as GitAPI['removeRemote'], From dfb3c614c86028250fa70a34c4f587f528b9eeb9 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 1 Sep 2026 16:37:40 +0300 Subject: [PATCH 04/17] fix: authenticate terminal WebSockets synchronously on Bun --- .../server/lib/security/request-security.js | 14 +++++-- .../lib/security/request-security.test.js | 28 +++++++------- packages/web/server/lib/terminal/runtime.js | 37 +++++++++++++++---- packages/web/server/lib/ui-auth/ui-auth.js | 4 +- 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/packages/web/server/lib/security/request-security.js b/packages/web/server/lib/security/request-security.js index f61e2bef..37ab64f5 100644 --- a/packages/web/server/lib/security/request-security.js +++ b/packages/web/server/lib/security/request-security.js @@ -103,7 +103,7 @@ export const createRequestSecurityRuntime = (deps) => { return { origins, hosts }; }; - const isRequestOriginAllowed = async (req) => { + const isRequestOriginAllowed = (req) => { const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : ''; if (!originHeader) { return false; @@ -120,15 +120,21 @@ export const createRequestSecurityRuntime = (deps) => { return false; } - const candidates = await getRequestOriginCandidates(req); - if (candidates.origins.has(origin.origin)) return true; + const forwardedHostHeader = req.headers['x-forwarded-host']; + const forwardedHost = (Array.isArray(forwardedHostHeader) ? forwardedHostHeader[0] : forwardedHostHeader || '') + .split(',')[0].trim().toLowerCase(); + const hostHeader = req.headers.host; + const host = forwardedHost || (Array.isArray(hostHeader) ? hostHeader[0] : hostHeader || '').trim().toLowerCase(); + if (host && host === origin.host.toLowerCase()) return true; // TLS commonly ends at a cloud edge before an HTTP hop to OpenChamber. // In that setup the browser's Origin is https while a generic reverse // proxy reports the upstream request as http. The external host remains // authoritative, so compare it directly instead of requiring the proxy to // preserve the browser-facing protocol. - return candidates.hosts.has(origin.host.toLowerCase()); + return getRequestOriginCandidates(req).then((candidates) => ( + candidates.origins.has(origin.origin) || candidates.hosts.has(origin.host.toLowerCase()) + )); }; return { diff --git a/packages/web/server/lib/security/request-security.test.js b/packages/web/server/lib/security/request-security.test.js index 05b6a1dd..c948bf36 100644 --- a/packages/web/server/lib/security/request-security.test.js +++ b/packages/web/server/lib/security/request-security.test.js @@ -9,55 +9,55 @@ describe('request security runtime', () => { test('allows packaged client origins for remote client transports', async () => { const runtime = createRuntime(); - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'openchamber-ui://app', host: '192.168.1.130:1202', }, socket: {}, - })).resolves.toBe(true); + })).toBe(true); - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'capacitor://localhost', host: '192.168.1.130:1202', }, socket: {}, - })).resolves.toBe(true); + })).toBe(true); // Android Capacitor WebView (androidScheme 'https') reports this origin. - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'https://localhost', host: '192.168.1.130:1202', }, socket: {}, - })).resolves.toBe(true); + })).toBe(true); }); test('rejects unknown origins', async () => { const runtime = createRuntime(); - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'https://evil.example.com', host: '192.168.1.130:1202', }, socket: {}, - })).resolves.toBe(false); + })).toBe(false); }); test('allows the external host when TLS terminates before an HTTP proxy hop', async () => { const runtime = createRuntime(); - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ headers: { origin: 'https://devchamber.example.com', host: 'devchamber.example.com', 'x-forwarded-proto': 'http', }, socket: {}, - })).resolves.toBe(true); + })).toBe(true); }); test('uses the forwarded external host without trusting a different origin', async () => { @@ -71,13 +71,13 @@ describe('request security runtime', () => { socket: {}, }; - await expect(runtime.isRequestOriginAllowed({ + expect(await runtime.isRequestOriginAllowed({ ...request, headers: { ...request.headers, origin: 'https://devchamber.example.com' }, - })).resolves.toBe(true); - await expect(runtime.isRequestOriginAllowed({ + })).toBe(true); + expect(await runtime.isRequestOriginAllowed({ ...request, headers: { ...request.headers, origin: 'https://evil.example.com' }, - })).resolves.toBe(false); + })).toBe(false); }); }); diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index d018b6fe..f8c7da28 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -276,16 +276,39 @@ export function createTerminalRuntime({ const upgradeHandler = (req, socket, head) => { if (parseRequestPathname(req.url) !== TERMINAL_WS_PATH) return; - void (async () => { + const accept = () => { + if (!wsServer) { rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); return; } try { - if (uiAuthController?.enabled) { - if (!await uiAuthController.ensureSessionToken(req, null)) { rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); return; } - if (!await isRequestOriginAllowed(req)) { rejectWebSocketUpgrade(socket, 403, 'Invalid origin'); return; } - } - if (!wsServer) { rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); return; } wsServer.handleUpgrade(req, socket, head, (ws) => wsServer.emit('connection', ws, req)); } catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); } - })(); + }; + const checkOrigin = () => { + try { + const result = isRequestOriginAllowed(req); + if (!(result instanceof Promise)) { + if (result) accept(); + else rejectWebSocketUpgrade(socket, 403, 'Invalid origin'); + return; + } + void result.then((allowed) => { + if (allowed) accept(); + else rejectWebSocketUpgrade(socket, 403, 'Invalid origin'); + }).catch(() => rejectWebSocketUpgrade(socket, 500, 'Upgrade failed')); + } catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); } + }; + if (!uiAuthController?.enabled) { accept(); return; } + try { + const result = uiAuthController.ensureSessionToken(req, null); + if (!(result instanceof Promise)) { + if (result) checkOrigin(); + else rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); + return; + } + void result.then((sessionToken) => { + if (sessionToken) checkOrigin(); + else rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); + }).catch(() => rejectWebSocketUpgrade(socket, 500, 'Upgrade failed')); + } catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); } }; server.on('upgrade', upgradeHandler); diff --git a/packages/web/server/lib/ui-auth/ui-auth.js b/packages/web/server/lib/ui-auth/ui-auth.js index ea3e0b99..aa61edde 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.js +++ b/packages/web/server/lib/ui-auth/ui-auth.js @@ -984,7 +984,9 @@ export const createUiAuth = ({ handlePasskeyList, handlePasskeyRevoke, handleResetAuth, - ensureSessionToken: async (req, _res) => { + ensureSessionToken: (req, _res) => { + const urlAuth = authenticateUrlAuthToken(req); + if (urlAuth) return clientSessionToken(urlAuth); return resolveAuthenticatedSessionToken(req); }, dispose, From 6c4923b8c829873edb69cf7a4baeac95209c49f3 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 1 Sep 2026 16:37:40 +0300 Subject: [PATCH 05/17] fix: support Bun-only remote dev deployments --- scripts/oc-dev.config.example.json | 2 ++ scripts/oc-dev.mjs | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/oc-dev.config.example.json b/scripts/oc-dev.config.example.json index 971c7f96..ffdc4425 100644 --- a/scripts/oc-dev.config.example.json +++ b/scripts/oc-dev.config.example.json @@ -14,6 +14,7 @@ "host": "example-host", "port": 3002, "dir": "testing-dev", + "lan": false, "apiOnly": true }, { @@ -22,6 +23,7 @@ "host": "example-host", "port": 3002, "dir": "testing-dev", + "lan": true, "apiOnly": false } ] diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs index 43e6c990..a4346d4a 100755 --- a/scripts/oc-dev.mjs +++ b/scripts/oc-dev.mjs @@ -389,6 +389,7 @@ async function deployRemoteWeb(options, config) { const dir = remote.dir; const port = String(remote.port); const apiOnly = remote.apiOnly ? 'true' : 'false'; + const bindHost = remote.lan === false ? '127.0.0.1' : '0.0.0.0'; const packageBase = path.basename(packageFile); if (!host || !dir || !port) throw new Error(`Remote deployment ${remote.id} must define host, dir, and port.`); @@ -400,9 +401,9 @@ async function deployRemoteWeb(options, config) { run('scp', ['-q', packageFile, `${host}:~/${dir}/releases/${packageBase}`]); }); step('Resetting remote install state', () => run('ssh', [host, `cd ~/${dir} && rm -f package.json package-lock.json pnpm-lock.yaml bun.lockb && rm -rf node_modules`])); - step('Preparing remote package manifest', () => run('ssh', [host, `cd ~/${dir} && ${REMOTE_RUNTIME_ENV}; npm init -y >/dev/null 2>&1`])); - step('Installing remote package', () => run('ssh', [host, `cd ~/${dir} && ${REMOTE_RUNTIME_ENV}; npm install ./releases/${packageBase}`])); - step(`Starting remote instance on ${host}:${port}`, () => run('ssh', [host, `set -e; cd ~/${dir}; ${REMOTE_RUNTIME_ENV}; PASSWORD_VALUE=$(grep '^export OPENCHAMBER_UI_PASSWORD=' ~/.bashrc 2>/dev/null | sed -E 's/.*=["“]?([^"”]+)["”]?/\\1/' || true); if [ -n "$PASSWORD_VALUE" ]; then export OPENCHAMBER_UI_PASSWORD="$PASSWORD_VALUE"; fi; if [ ${quote(apiOnly)} = 'true' ]; then export OPENCHAMBER_API_ONLY=true; fi; OPENCHAMBER_HOST=0.0.0.0 node ./node_modules/@openchamber/web/bin/cli.js --port ${quote(port)} >/dev/null 2>&1; sleep 0.5; if command -v lsof >/dev/null 2>&1; then lsof -ti :${quote(port)} >/dev/null 2>&1 || exit 1; fi`])); + step('Preparing remote package manifest', () => run('ssh', [host, `set -e; cd ~/${dir}; ${REMOTE_RUNTIME_ENV}; if command -v bun >/dev/null 2>&1; then bun init -y; else npm init -y; fi`])); + step('Installing remote package', () => run('ssh', [host, `set -e; cd ~/${dir}; ${REMOTE_RUNTIME_ENV}; if command -v bun >/dev/null 2>&1; then bun add ./releases/${packageBase}; else npm install ./releases/${packageBase}; fi`])); + step(`Starting remote instance on ${host}:${port}`, () => run('ssh', [host, `set -e; cd ~/${dir}; ${REMOTE_RUNTIME_ENV}; PASSWORD_VALUE=$(grep '^export OPENCHAMBER_UI_PASSWORD=' ~/.bashrc 2>/dev/null | sed -E 's/.*=["“]?([^"”]+)["”]?/\\1/' || true); if [ -n "$PASSWORD_VALUE" ]; then export OPENCHAMBER_UI_PASSWORD="$PASSWORD_VALUE"; fi; if [ ${quote(apiOnly)} = 'true' ]; then export OPENCHAMBER_API_ONLY=true; fi; if command -v bun >/dev/null 2>&1; then OPENCHAMBER_HOST=${quote(bindHost)} bun ./node_modules/@openchamber/web/bin/cli.js --port ${quote(port)} >/dev/null 2>&1; else OPENCHAMBER_HOST=${quote(bindHost)} node ./node_modules/@openchamber/web/bin/cli.js --port ${quote(port)} >/dev/null 2>&1; fi; sleep 0.5; if command -v lsof >/dev/null 2>&1; then lsof -ti :${quote(port)} >/dev/null 2>&1 || exit 1; fi`])); log.success(`Remote deployment ready: ${host}:${port}`); } From 29ca97036269ea63e241fba855bfb62c0f84e9a5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 1 Sep 2026 17:00:32 +0300 Subject: [PATCH 06/17] fix: refresh terminal renderer after fonts load --- .../components/terminal/TerminalViewport.tsx | 49 ++++++++++++------- .../ui/src/components/views/TerminalView.tsx | 1 + .../__tests__/terminalViewportRemount.test.ts | 9 ++++ packages/ui/src/vite-env.d.ts | 1 + 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index 33d9eded..a047677d 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -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 => 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 => { - if (typeof window === 'undefined') return Promise.resolve(); - const loader = (window as typeof window & { __openchamberEnsureNerdFonts?: () => Promise }).__openchamberEnsureNerdFonts; - if (typeof loader !== 'function') return Promise.resolve(); - return Promise.race([ - Promise.resolve(loader()).catch(() => undefined), - new Promise((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 => + Promise.resolve(window.__openchamberEnsureNerdFonts?.()).catch(() => undefined); + +const waitForTerminalFonts = (font: MonoFontOption) => { + const loaded = Promise.all([loadMonoFont(font), loadNerdFonts()]).then(() => undefined); + const loadedBeforeTimeout = new Promise((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(({ - 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(null); @@ -236,7 +243,8 @@ const TerminalViewport = React.forwardRef(({ 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(({ 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(({ writeEpochRef.current += 1; rendererReadyRef.current = false; }; - }, [fit, fontFamily, fontSize, rendererGeneration, theme]); + }, [fit, fontFamily, fontSize, monoFont, rendererGeneration, theme]); React.useEffect(() => { const terminal = terminalRef.current; diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 8e3e83f7..5e25ca0b 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -1140,6 +1140,7 @@ export const TerminalView: React.FC = ({ visible }) => { onInput={handleViewportInput} onResize={handleViewportResize} theme={xtermTheme} + monoFont={monoFont} fontFamily={resolvedFontStack} fontSize={terminalFontSize} enableTouchScroll={useTouchTerminalInput} diff --git a/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts b/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts index d03379c1..1de0e9a8 100644 --- a/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts +++ b/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts @@ -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)'); + }); }); diff --git a/packages/ui/src/vite-env.d.ts b/packages/ui/src/vite-env.d.ts index 2e3fa7b4..0c853341 100644 --- a/packages/ui/src/vite-env.d.ts +++ b/packages/ui/src/vite-env.d.ts @@ -1,6 +1,7 @@ /// interface Window { + __openchamberEnsureNerdFonts?: () => Promise; __opencodeDebug?: { getLastAssistantMessage: () => unknown; getAllMessages: (truncate?: boolean) => unknown[]; From 62a18f07a0c52ff5cd8e45648568fc1913ab5b5f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 1 Sep 2026 18:16:54 +0300 Subject: [PATCH 07/17] feat: add exe.dev provider logo --- packages/ui/src/assets/provider-logos/exe-dev.svg | 9 +++++++++ packages/ui/src/hooks/useProviderLogo.test.ts | 11 +++++++++++ packages/ui/src/hooks/useProviderLogo.ts | 3 ++- 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/assets/provider-logos/exe-dev.svg create mode 100644 packages/ui/src/hooks/useProviderLogo.test.ts diff --git a/packages/ui/src/assets/provider-logos/exe-dev.svg b/packages/ui/src/assets/provider-logos/exe-dev.svg new file mode 100644 index 00000000..c761f9e5 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/exe-dev.svg @@ -0,0 +1,9 @@ + + exe.dev + + + + + + + diff --git a/packages/ui/src/hooks/useProviderLogo.test.ts b/packages/ui/src/hooks/useProviderLogo.test.ts new file mode 100644 index 00000000..940b2bd2 --- /dev/null +++ b/packages/ui/src/hooks/useProviderLogo.test.ts @@ -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,'); + }); +}); diff --git a/packages/ui/src/hooks/useProviderLogo.ts b/packages/ui/src/hooks/useProviderLogo.ts index 011d1110..80ded66d 100644 --- a/packages/ui/src/hooks/useProviderLogo.ts +++ b/packages/ui/src/hooks/useProviderLogo.ts @@ -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)]; From da6ab52a658a5121407469a1cb4e12b50acaadc6 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 1 Sep 2026 19:22:09 +0300 Subject: [PATCH 08/17] feat: add exe.dev usage tracking --- .../sections/usage/QuotaCredentials.tsx | 17 +++-- .../components/sections/usage/UsagePage.tsx | 4 +- .../ui/src/lib/i18n/messages/de.settings.ts | 2 + .../ui/src/lib/i18n/messages/en.settings.ts | 2 + .../ui/src/lib/i18n/messages/es.settings.ts | 2 + .../ui/src/lib/i18n/messages/fr.settings.ts | 2 + .../ui/src/lib/i18n/messages/ja.settings.ts | 2 + .../ui/src/lib/i18n/messages/ko.settings.ts | 2 + .../ui/src/lib/i18n/messages/pl.settings.ts | 2 + .../src/lib/i18n/messages/pt-BR.settings.ts | 2 + .../ui/src/lib/i18n/messages/tr.settings.ts | 2 + .../ui/src/lib/i18n/messages/uk.settings.ts | 2 + .../src/lib/i18n/messages/zh-CN.settings.ts | 2 + .../src/lib/i18n/messages/zh-TW.settings.ts | 2 + packages/ui/src/lib/quota/providers/index.ts | 1 + packages/ui/src/types/quota.ts | 1 + packages/vscode/src/DOCUMENTATION.md | 1 + packages/vscode/src/bridge-system-runtime.ts | 2 +- packages/vscode/src/exeDevQuota.test.ts | 30 ++++++++ packages/vscode/src/exeDevQuota.ts | 67 +++++++++++++++++ packages/vscode/src/quotaCredentials.ts | 7 +- packages/vscode/src/quotaProviders.ts | 14 ++++ .../web/server/lib/quota/DOCUMENTATION.md | 3 +- .../server/lib/quota/credentials/providers.js | 4 + .../web/server/lib/quota/credentials/store.js | 2 +- .../lib/quota/credentials/store.test.js | 8 +- .../web/server/lib/quota/providers/exe-dev.js | 75 +++++++++++++++++++ .../lib/quota/providers/exe-dev.test.js | 47 ++++++++++++ .../web/server/lib/quota/providers/index.js | 7 ++ packages/web/server/lib/quota/routes.js | 2 + 30 files changed, 300 insertions(+), 16 deletions(-) create mode 100644 packages/vscode/src/exeDevQuota.test.ts create mode 100644 packages/vscode/src/exeDevQuota.ts create mode 100644 packages/web/server/lib/quota/providers/exe-dev.js create mode 100644 packages/web/server/lib/quota/providers/exe-dev.test.js diff --git a/packages/ui/src/components/sections/usage/QuotaCredentials.tsx b/packages/ui/src/components/sections/usage/QuotaCredentials.tsx index abcdc8aa..840cc905 100644 --- a/packages/ui/src/components/sections/usage/QuotaCredentials.tsx +++ b/packages/ui/src/components/sections/usage/QuotaCredentials.tsx @@ -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(null); - const [values, setValues] = React.useState>({}); + const [values, setValues] = React.useState({}); 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) => ; + const field = (name: keyof CredentialPayload, label: string, placeholder: string) => ; return

{providerName}

+ {providerId === 'exe-dev' &&
+

{t('settings.providers.page.quotaCredentials.exeDevTokenInstructions')}

+ {EXE_DEV_TOKEN_COMMAND} +
} {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'))}
diff --git a/packages/ui/src/components/sections/usage/UsagePage.tsx b/packages/ui/src/components/sections/usage/UsagePage.tsx index 7e8e3ccc..679d73b1 100644 --- a/packages/ui/src/components/sections/usage/UsagePage.tsx +++ b/packages/ui/src/components/sections/usage/UsagePage.tsx @@ -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 = () => {
)} - {(selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && ( + {(selectedProviderId === 'exe-dev' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && ( )} diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 86dc0985..8e7ad3c5 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index e0c6468f..4b44ad31 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index b72f9e26..2b656990 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index ae7d5d4e..0fe60bea 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index a68f0fc7..729551a6 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -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 の認証情報を検証できませんでした。', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 4fdb93af..48d6aa8b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -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 인증 정보를 검증할 수 없습니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index f8272cc1..9d89d843 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 3f2827c5..9261f8ca 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index ead5621c..02651073 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -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ı.', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index f3d88411..4116ef42 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index c0eef58a..01024ad1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -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 凭据。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index c0639769..7e5d3f25 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -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 憑證。', diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 96a4906c..749f5516 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -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' }, ]; diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 059e4b98..1524ca51 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -18,6 +18,7 @@ export type QuotaProviderId = | 'opencode-go' | 'crof' | 'deepseek' + | 'exe-dev' | 'neuralwatt' | 'xai'; diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index c89e0799..35029076 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -72,6 +72,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews - Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`). - Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade. - Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API). + - Quota handlers keep managed exe.dev, Ollama Cloud, and Cursor credentials in the extension data directory with the same private-file contract as the web runtime. exe.dev uses one command-scoped usage token for the aggregate billing shared by every `exe-*` model provider. - `opencode-upgrade-runtime.ts` - Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior. diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index 96de0bee..a99e8181 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -555,7 +555,7 @@ export async function handleSystemBridgeMessage( case 'api:quota:credentials': { const { providerId, method, credential: input } = (payload || {}) as { providerId?: ManagedProvider; method?: string; credential?: unknown }; try { - if (!providerId || !['ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' }; + if (!providerId || !['exe-dev', 'ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' }; if (method === 'GET') return { id, type, success: true, data: credentialStatus(providerId) }; if (method === 'DELETE') { deleteCredential(providerId); return { id, type, success: true, data: { configured: false } }; } if (method === 'IMPORT') { diff --git a/packages/vscode/src/exeDevQuota.test.ts b/packages/vscode/src/exeDevQuota.test.ts new file mode 100644 index 00000000..ce026823 --- /dev/null +++ b/packages/vscode/src/exeDevQuota.test.ts @@ -0,0 +1,30 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { fetchExeDevUsage, parseExeDevUsage } from './exeDevQuota'; + +const payload = { + monthly_allowance_usd: 20, + period_end: '2026-10-01T00:00:00Z', + total_cost_usd: 0.11, +}; + +describe('exe.dev quota', () => { + it('parses monthly credit usage', () => { + const windows = parseExeDevUsage(payload); + assert.ok(windows); + assert.ok(Math.abs((windows.monthly.usedPercent ?? 0) - 0.55) < 0.0001); + assert.equal(windows.monthly.valueLabel, '$0.11 / $20.00'); + }); + + it('executes only the billing usage command', async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const windows = await fetchExeDevUsage('test-token', async (url, init) => { + requests.push({ url: String(url), init }); + return Response.json(payload); + }); + assert.equal(windows.monthly.valueLabel, '$0.11 / $20.00'); + assert.equal(requests[0]?.url, 'https://exe.dev/exec'); + assert.equal(requests[0]?.init?.body, 'billing credits usage --group=day --json'); + assert.equal(new Headers(requests[0]?.init?.headers).get('Authorization'), 'Bearer test-token'); + }); +}); diff --git a/packages/vscode/src/exeDevQuota.ts b/packages/vscode/src/exeDevQuota.ts new file mode 100644 index 00000000..424cb7e9 --- /dev/null +++ b/packages/vscode/src/exeDevQuota.ts @@ -0,0 +1,67 @@ +type ExeDevUsageWindow = { + usedPercent: number | null; + remainingPercent: number | null; + windowSeconds: null; + resetAfterSeconds: number | null; + resetAt: number; + resetAtFormatted: string; + resetAfterFormatted: string | null; + valueLabel: string; +}; + +type ExeDevUsagePayload = { + total_cost_usd?: number | null; + monthly_allowance_usd?: number | null; + period_end?: string | null; +}; + +const EXEC_URL = 'https://exe.dev/exec'; +const USAGE_COMMAND = 'billing credits usage --group=day --json'; + +const numberValue = (value: number | null | undefined) => { + if (value === null || value === undefined || !Number.isFinite(value)) return null; + return value; +}; + +export const parseExeDevUsage = (payload: ExeDevUsagePayload | null): Record | null => { + if (!payload) return null; + const totalCost = numberValue(payload.total_cost_usd); + const monthlyAllowance = numberValue(payload.monthly_allowance_usd); + const resetAt = payload.period_end ? Date.parse(payload.period_end) : Number.NaN; + if (totalCost === null || monthlyAllowance === null || monthlyAllowance < 0 || !Number.isFinite(resetAt)) return null; + const usedPercent = monthlyAllowance > 0 ? Math.min(100, Math.max(0, (totalCost / monthlyAllowance) * 100)) : null; + const remainingPercent = usedPercent === null ? null : Math.max(0, 100 - usedPercent); + const resetAfterSeconds = Math.max(0, Math.floor((resetAt - Date.now()) / 1000)); + return { + monthly: { + usedPercent, + remainingPercent, + windowSeconds: null, + resetAfterSeconds, + resetAt, + resetAtFormatted: new Date(resetAt).toLocaleString(), + resetAfterFormatted: null, + valueLabel: `$${totalCost.toFixed(2)} / $${monthlyAllowance.toFixed(2)}`, + }, + }; +}; + +export const fetchExeDevUsage = async (usageToken: string, fetchImpl: typeof fetch = fetch) => { + const response = await fetchImpl(EXEC_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${usageToken}`, + 'Content-Type': 'text/plain', + 'User-Agent': 'OpenChamber quota provider', + }, + body: USAGE_COMMAND, + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 401 || response.status === 403) throw new Error('exe.dev authentication failed'); + if (!response.ok) throw new Error(`exe.dev usage API returned HTTP ${response.status}`); + const payload: ExeDevUsagePayload | null = await response.text().then((text) => JSON.parse(text)).catch(() => null); + const windows = parseExeDevUsage(payload); + if (!windows) throw new Error('exe.dev usage data could not be parsed'); + return windows; +}; diff --git a/packages/vscode/src/quotaCredentials.ts b/packages/vscode/src/quotaCredentials.ts index 3aa185dc..b7f5968d 100644 --- a/packages/vscode/src/quotaCredentials.ts +++ b/packages/vscode/src/quotaCredentials.ts @@ -2,10 +2,11 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; +import { fetchExeDevUsage } from './exeDevQuota'; -export type ManagedProvider = 'ollama-cloud' | 'cursor'; +export type ManagedProvider = 'exe-dev' | 'ollama-cloud' | 'cursor'; export type ManagedCredential = Record; -const providers = new Set(['ollama-cloud', 'cursor']); +const providers = new Set(['exe-dev', 'ollama-cloud', 'cursor']); const directory = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota'); const target = (provider: ManagedProvider) => { if (!providers.has(provider)) throw new Error('Unsupported credential provider'); @@ -15,6 +16,7 @@ const clean = (value: unknown) => typeof value === 'string' && !/[\r\n]/.test(va export const normalizeCredential = (provider: ManagedProvider, value: unknown): ManagedCredential | null => { const data = value && typeof value === 'object' ? value as Record : {}; + if (provider === 'exe-dev') return clean(data.usageToken) ? { usageToken: clean(data.usageToken) } : null; if (provider === 'ollama-cloud') return clean(data.cookie) ? { cookie: clean(data.cookie) } : null; const accessToken = clean(data.accessToken); const refreshToken = clean(data.refreshToken); @@ -52,6 +54,7 @@ export const importCursorCredential = () => { }; export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => { + if (provider === 'exe-dev') await fetchExeDevUsage(credential.usageToken); if (provider === 'ollama-cloud') { const response = await fetch('https://ollama.com/settings', { headers: { Cookie: credential.cookie }, redirect: 'manual', signal: AbortSignal.timeout(15_000) }); if (!response.ok || (response.status >= 300 && response.status < 400)) throw new Error('Ollama Cloud authentication failed'); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index a9ce3204..c275a07d 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import { fetchOpenCodeGoUsage } from './opencodeGoQuota'; import { deleteLegacyOpenCodeGoCredential, readCredential } from './quotaCredentials'; import { getProviderAuth, updateProviderAuth } from './opencodeAuth'; +import { fetchExeDevUsage } from './exeDevQuota'; type AuthEntry = Record | string; type AuthFile = Record; @@ -774,6 +775,7 @@ export const listConfiguredQuotaProviders = () => { if (openCodeGoAuth && (typeof openCodeGoAuth.key === 'string' || typeof openCodeGoAuth.token === 'string')) configured.add('opencode-go'); if (readCredential('ollama-cloud')) configured.add('ollama-cloud'); if (readCredential('cursor')) configured.add('cursor'); + if (readCredential('exe-dev')) configured.add('exe-dev'); const anthropicAuth = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])); if (anthropicAuth && ((anthropicAuth as Record).access || (anthropicAuth as Record).token)) { @@ -1946,6 +1948,16 @@ const fetchOllamaCloudQuota = async (): Promise => { } }; +const fetchExeDevQuota = async (): Promise => { + const usageToken = readCredential('exe-dev')?.usageToken; + if (!usageToken) return buildResult({ providerId: 'exe-dev', providerName: 'exe.dev', ok: false, configured: false, error: 'Not configured' }); + try { + return buildResult({ providerId: 'exe-dev', providerName: 'exe.dev', ok: true, configured: true, usage: { windows: await fetchExeDevUsage(usageToken) } }); + } catch (error) { + return buildResult({ providerId: 'exe-dev', providerName: 'exe.dev', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' }); + } +}; + const fetchCursorQuota = async (): Promise => { const accessToken = readCredential('cursor')?.accessToken; if (!accessToken) return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: false, error: 'Not configured' }); @@ -2868,6 +2880,8 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : ''; export const normalizers = { + 'exe-dev': (value) => { + const usageToken = clean(value?.usageToken); + return usageToken ? { usageToken } : null; + }, 'ollama-cloud': (value) => { const cookie = clean(value?.cookie); return cookie ? { cookie } : null; diff --git a/packages/web/server/lib/quota/credentials/store.js b/packages/web/server/lib/quota/credentials/store.js index a83b2008..3027d8d4 100644 --- a/packages/web/server/lib/quota/credentials/store.js +++ b/packages/web/server/lib/quota/credentials/store.js @@ -2,7 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -const MANAGED_QUOTA_PROVIDERS = new Set(['ollama-cloud', 'cursor']); +const MANAGED_QUOTA_PROVIDERS = new Set(['exe-dev', 'ollama-cloud', 'cursor']); const credentialsDirectory = () => path.join( process.env.OPENCHAMBER_DATA_DIR diff --git a/packages/web/server/lib/quota/credentials/store.test.js b/packages/web/server/lib/quota/credentials/store.test.js index 6ad09d25..68bf6726 100644 --- a/packages/web/server/lib/quota/credentials/store.test.js +++ b/packages/web/server/lib/quota/credentials/store.test.js @@ -10,12 +10,12 @@ process.env.OPENCHAMBER_DATA_DIR = temporaryDirectory; describe('quota credential store', () => { it('uses owner-only permissions and rejects arbitrary provider paths', () => { - writeQuotaCredential('ollama-cloud', { cookie: 'secret' }); + writeQuotaCredential('exe-dev', { usageToken: 'secret' }); expect(fs.statSync(path.join(temporaryDirectory, 'quota')).mode & 0o777).toBe(0o700); - expect(fs.statSync(path.join(temporaryDirectory, 'quota', 'ollama-cloud.json')).mode & 0o777).toBe(0o600); - expect(readQuotaCredential('ollama-cloud', (value) => value)).toEqual({ cookie: 'secret' }); + expect(fs.statSync(path.join(temporaryDirectory, 'quota', 'exe-dev.json')).mode & 0o777).toBe(0o600); + expect(readQuotaCredential('exe-dev', (value) => value)).toEqual({ usageToken: 'secret' }); expect(() => writeQuotaCredential('../escape', {})).toThrow('Unsupported credential provider'); - deleteQuotaCredential('ollama-cloud'); + deleteQuotaCredential('exe-dev'); }); it('removes the obsolete OpenCode Go credential without parsing it', () => { diff --git a/packages/web/server/lib/quota/providers/exe-dev.js b/packages/web/server/lib/quota/providers/exe-dev.js new file mode 100644 index 00000000..f3f9155b --- /dev/null +++ b/packages/web/server/lib/quota/providers/exe-dev.js @@ -0,0 +1,75 @@ +import { readManagedCredential } from '../credentials/providers.js'; +import { asObject, buildResult, formatMoney, toNumber, toTimestamp, toUsageWindow } from '../utils/index.js'; + +export const providerId = 'exe-dev'; +export const providerName = 'exe.dev'; +export const aliases = ['exe-dev']; +const EXEC_URL = 'https://exe.dev/exec'; +const USAGE_COMMAND = 'billing credits usage --group=day --json'; + +export const parseExeDevUsage = (payload) => { + const data = asObject(payload); + if (!data) return null; + + const totalCost = toNumber(data.total_cost_usd); + const monthlyAllowance = toNumber(data.monthly_allowance_usd); + const resetAt = toTimestamp(data.period_end); + if (totalCost === null || monthlyAllowance === null || monthlyAllowance < 0 || resetAt === null) return null; + + const usedPercent = monthlyAllowance > 0 + ? Math.min(100, Math.max(0, (totalCost / monthlyAllowance) * 100)) + : null; + const spent = formatMoney(totalCost); + const allowance = formatMoney(monthlyAllowance); + if (spent === null || allowance === null) return null; + + return { + monthly: toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt, + valueLabel: `$${spent} / $${allowance}`, + }), + }; +}; + +export const fetchExeDevUsage = async (credential, fetchImpl = fetch) => { + const response = await fetchImpl(EXEC_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${credential.usageToken}`, + 'Content-Type': 'text/plain', + 'User-Agent': 'OpenChamber quota provider', + }, + body: USAGE_COMMAND, + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 401 || response.status === 403) throw new Error('exe.dev authentication failed'); + if (!response.ok) throw new Error(`exe.dev usage API returned HTTP ${response.status}`); + const windows = parseExeDevUsage(await response.json().catch(() => null)); + if (!windows) throw new Error('exe.dev usage data could not be parsed'); + return windows; +}; + +export const isConfigured = () => Boolean(readManagedCredential(providerId)); + +export const fetchQuota = async () => { + const credential = readManagedCredential(providerId); + if (!credential) { + return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' }); + } + + try { + const windows = await fetchExeDevUsage(credential); + return buildResult({ providerId, providerName, ok: true, configured: true, usage: { windows } }); + } catch (error) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed', + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/exe-dev.test.js b/packages/web/server/lib/quota/providers/exe-dev.test.js new file mode 100644 index 00000000..5b2a5d92 --- /dev/null +++ b/packages/web/server/lib/quota/providers/exe-dev.test.js @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'bun:test'; +import { fetchExeDevUsage, parseExeDevUsage } from './exe-dev.js'; + +const payload = { + allowance_spend_usd: 0.11, + extra_spend_usd: 0, + group: 'day', + month: '2026-09', + monthly_allowance_usd: 20, + period_end: '2026-10-01T00:00:00Z', + period_start: '2026-09-01T00:00:00Z', + total_cost_usd: 0.11, + total_requests: 17, +}; + +describe('exe.dev quota provider', () => { + it('parses monthly credit usage', () => { + const windows = parseExeDevUsage(payload); + expect(windows?.monthly.usedPercent).toBeCloseTo(0.55); + expect(windows?.monthly.valueLabel).toBe('$0.11 / $20.00'); + expect(windows?.monthly.resetAt).toBe(Date.parse('2026-10-01T00:00:00Z')); + }); + + it('sends the scoped billing command without exposing the token elsewhere', async () => { + const requests = []; + const windows = await fetchExeDevUsage({ usageToken: 'test-token' }, async (url, init) => { + requests.push({ url, init }); + return Response.json(payload); + }); + expect(windows.monthly.valueLabel).toBe('$0.11 / $20.00'); + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe('https://exe.dev/exec'); + expect(requests[0].init.method).toBe('POST'); + expect(requests[0].init.body).toBe('billing credits usage --group=day --json'); + expect(requests[0].init.headers.Authorization).toBe('Bearer test-token'); + }); + + it('rejects malformed successful responses', async () => { + await expect(fetchExeDevUsage({ usageToken: 'test-token' }, async () => Response.json({}))) + .rejects.toThrow('could not be parsed'); + }); + + it('reports authentication failure without including the token', async () => { + await expect(fetchExeDevUsage({ usageToken: 'test-token' }, async () => new Response('', { status: 401 }))) + .rejects.toThrow('exe.dev authentication failed'); + }); +}); diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index 3ae4cc99..b6d7be3d 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -13,6 +13,7 @@ import * as copilot from './copilot.js'; import * as crof from './crof.js'; import * as cursor from './cursor.js'; import * as deepseek from './deepseek.js'; +import * as exeDev from './exe-dev.js'; import * as google from './google/index.js'; import * as kimi from './kimi.js'; import * as nanogpt from './nanogpt.js'; @@ -59,6 +60,12 @@ const registry = { isConfigured: deepseek.isConfigured, fetchQuota: deepseek.fetchQuota }, + 'exe-dev': { + providerId: exeDev.providerId, + providerName: exeDev.providerName, + isConfigured: exeDev.isConfigured, + fetchQuota: exeDev.fetchQuota + }, google: { providerId: google.providerId, providerName: google.providerName, diff --git a/packages/web/server/lib/quota/routes.js b/packages/web/server/lib/quota/routes.js index 570fb253..4e4f9f05 100644 --- a/packages/web/server/lib/quota/routes.js +++ b/packages/web/server/lib/quota/routes.js @@ -2,8 +2,10 @@ import express from 'express'; import { deleteManagedCredential, getManagedCredentialStatus, normalizers, readManagedCredential, writeManagedCredential } from './credentials/providers.js'; import { fetchOllamaCloudUsage } from './providers/ollama-cloud.js'; import { importCursorCredential, validateCursorCredential } from './providers/cursor.js'; +import { fetchExeDevUsage } from './providers/exe-dev.js'; const validators = { + 'exe-dev': fetchExeDevUsage, 'ollama-cloud': fetchOllamaCloudUsage, cursor: validateCursorCredential, }; From a87f068256cb52eeda1b87609a69ce8e0537c29d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 1 Sep 2026 21:12:28 +0300 Subject: [PATCH 09/17] fix: support dev server previews over relay --- packages/electron/README.md | 1 + packages/electron/main.mjs | 13 +- packages/electron/preload.mjs | 31 ++++- packages/electron/relay-dev-tunnel.mjs | 121 ++++++++++++++++++ packages/electron/relay-dev-tunnel.test.mjs | 96 ++++++++++++++ packages/ui/src/lib/browser/devTunnel.test.ts | 89 +++++++++++-- packages/ui/src/lib/browser/devTunnel.ts | 83 ++++++++++-- packages/ui/src/lib/desktopNative.ts | 28 ++++ .../server/lib/dev-tunnel/DOCUMENTATION.md | 17 ++- packages/web/server/lib/dev-tunnel/runtime.js | 8 +- .../web/server/lib/dev-tunnel/tunnel.test.js | 20 +++ .../web/server/lib/relay/DOCUMENTATION.md | 2 +- packages/web/server/lib/relay/tunnel-host.js | 4 +- .../web/server/lib/relay/tunnel-host.test.js | 10 +- packages/web/server/lib/ui-auth/ui-auth.js | 1 + .../web/server/lib/ui-auth/ui-auth.test.js | 16 +++ 16 files changed, 504 insertions(+), 36 deletions(-) create mode 100644 packages/electron/relay-dev-tunnel.mjs create mode 100644 packages/electron/relay-dev-tunnel.test.mjs diff --git a/packages/electron/README.md b/packages/electron/README.md index c5c7df08..41490145 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -155,6 +155,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u - SSH host import, connections, logs, and port forwarding. - SSH uses OpenSSH ControlMaster on macOS/Linux. Windows uses independent hidden OpenSSH processes for setup commands and each long-lived forward because Win32 OpenSSH does not support ControlMaster reliably. - Tunnel lifecycle integration through the web server runtime. +- Remote dev-server previews use a direct WebSocket tunnel when the instance has an HTTP address. Relay-only instances keep the encrypted relay transport in the renderer and bridge its raw bytes to the browser panel through a local Electron listener. - Auto-update checks, downloads, and restart/apply flow. - The browser panel's own session (`persist:openchamber-browser`): its storage is cleared only through the scoped clear-data command, and camera, microphone, diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 2a0de81b..64a08849 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, net as electronNet, Notification, powerMonitor, powerSaveBlocker, protocol, screen, session, shell, webContents } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, Menu, MessageChannelMain, nativeTheme, net as electronNet, Notification, powerMonitor, powerSaveBlocker, protocol, screen, session, shell, webContents } from 'electron'; import contextMenu from 'electron-context-menu'; import log from 'electron-log/main.js'; import dgram from 'node:dgram'; @@ -33,6 +33,7 @@ import { } from './linux-autostart.mjs'; import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs'; import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs'; +import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs'; import { attachRendererRecovery } from './renderer-recovery.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; @@ -3839,6 +3840,7 @@ const runSpecChain = (specs, appName) => { // The tunnel client lives in the web package (it already has a WebSocket // client) and is loaded only if the user actually previews a remote dev server. let devTunnelClientPromise = null; +const relayDevTunnelBridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannelMain(), logger: log }); const getDevTunnelClient = async () => { if (!devTunnelClientPromise) { devTunnelClientPromise = import('@openchamber/web/server/lib/dev-tunnel/client.js') @@ -3852,6 +3854,7 @@ const getDevTunnelClient = async () => { }; const closeAllDevTunnels = () => { + relayDevTunnelBridge.closeAll(); if (!devTunnelClientPromise) return; const pending = devTunnelClientPromise; devTunnelClientPromise = null; @@ -3959,6 +3962,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => { if (!baseUrl) throw new Error('baseUrl is required'); if (!(port > 0 && port <= 65535)) throw new Error('A valid port is required'); + if (args.relay === true) { + const targetKey = typeof args.targetKey === 'string' ? args.targetKey.trim() : ''; + return relayDevTunnelBridge.open({ targetKey, remotePort: port, webContents: browserWindow?.webContents }); + } + const headers = {}; const requestHeaders = args.requestHeaders && typeof args.requestHeaders === 'object' ? args.requestHeaders : {}; for (const [name, value] of Object.entries(requestHeaders)) { @@ -3981,6 +3989,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => { return { closed: client.close({ baseUrl, port }) }; } + case 'desktop_relay_dev_tunnel_close_all': + return { closed: relayDevTunnelBridge.closeForWebContents(browserWindow?.webContents.id) }; + /** * Forces prefers-color-scheme for one previewed page. * diff --git a/packages/electron/preload.mjs b/packages/electron/preload.mjs index f3f96667..7cecec8c 100644 --- a/packages/electron/preload.mjs +++ b/packages/electron/preload.mjs @@ -158,14 +158,41 @@ ipcRenderer.on('openchamber:emit', (_evt, payload) => { dispatchNativeEvent(event, payload.detail); }); +const relayDevTunnelPorts = new Map(); +let relayDevTunnelHandler = null; +ipcRenderer.on('openchamber:relay-dev-tunnel-connect', (event, payload) => { + if (!isLocalPage || !payload || typeof payload.connectionId !== 'string' || !event.ports?.[0]) return; + const port = event.ports[0]; + relayDevTunnelPorts.set(payload.connectionId, port); + port.onmessage = (messageEvent) => relayDevTunnelHandler?.({ + connectionId: payload.connectionId, + remotePort: payload.remotePort, + message: messageEvent.data, + }); + port.start(); + relayDevTunnelHandler?.({ connectionId: payload.connectionId, remotePort: payload.remotePort, message: { type: 'connect' } }); +}); + // The desktop bridge is exposed on all pages; the main-process gate in // ipcMain.handle('openchamber:invoke') decides per-command what is safe // for non-local callers (window/host-switcher ops yes, file/shell ops // no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs. -contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', { +const desktopBridge = { invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}), openDialog: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}), grantFileAccess: (filePath) => ipcRenderer.invoke('openchamber:file:grant-existing', filePath), openExternal: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }), listen: async (event, handler) => addListener(event, handler), -}); +}; + +if (isLocalPage) { + desktopBridge.relayDevTunnelListen = (handler) => { + relayDevTunnelHandler = typeof handler === 'function' ? handler : null; + }; + desktopBridge.relayDevTunnelPost = (connectionId, message) => { + relayDevTunnelPorts.get(connectionId)?.postMessage(message); + if (message?.type === 'close') relayDevTunnelPorts.delete(connectionId); + }; +} + +contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', desktopBridge); diff --git a/packages/electron/relay-dev-tunnel.mjs b/packages/electron/relay-dev-tunnel.mjs new file mode 100644 index 00000000..de9bd6c1 --- /dev/null +++ b/packages/electron/relay-dev-tunnel.mjs @@ -0,0 +1,121 @@ +import net from 'node:net'; +import { randomUUID } from 'node:crypto'; + +const CONNECTION_READY_TIMEOUT_MS = 15_000; + +const listen = (server) => new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + const address = server.address(); + const port = Number(address?.port); + if (!Number.isInteger(port) || port <= 0) { + reject(new Error('Failed to bind a local relay tunnel port')); + return; + } + resolve(port); + }); +}); + +const messageData = (event) => { + if (event?.type === 'ready' || event?.type === 'data' || event?.type === 'close') return event; + return event?.data ?? null; +}; + +export const createRelayDevTunnelBridge = ({ createMessageChannel, logger = console } = {}) => { + const tunnels = new Map(); + + const closeTunnel = (key) => { + const tunnel = tunnels.get(key); + if (!tunnel) return false; + tunnels.delete(key); + for (const connection of tunnel.connections.values()) connection.close(); + try { tunnel.server.close(); } catch { /* already closing */ } + return true; + }; + + return { + async open({ targetKey, remotePort, webContents }) { + const port = Number.parseInt(String(remotePort), 10); + if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error('A valid remote port is required'); + if (!targetKey) throw new Error('A relay target key is required'); + if (!webContents || webContents.isDestroyed?.()) throw new Error('The desktop window is unavailable'); + + const key = `${webContents.id}|${targetKey}|${port}`; + const existing = tunnels.get(key); + if (existing) return { localPort: existing.localPort, reused: true }; + + const connections = new Map(); + const server = net.createServer((socket) => { + socket.setNoDelay(true); + socket.pause(); + const connectionId = randomUUID(); + const { port1, port2 } = createMessageChannel(); + let closed = false; + const readyTimer = setTimeout(() => close(), CONNECTION_READY_TIMEOUT_MS); + + const close = () => { + if (closed) return; + closed = true; + clearTimeout(readyTimer); + connections.delete(connectionId); + try { port1.postMessage({ type: 'close' }); } catch { /* already closed */ } + try { socket.destroy(); } catch { /* already closed */ } + try { port1.close(); } catch { /* already closed */ } + }; + connections.set(connectionId, { close }); + + port1.on('message', (event) => { + const message = messageData(event); + if (!message) return; + if (message.type === 'ready') { + clearTimeout(readyTimer); + socket.resume(); + return; + } + if (message.type === 'data' && message.data) { + socket.write(Buffer.from(message.data)); + return; + } + if (message.type === 'close') close(); + }); + port1.on('close', close); + port1.start?.(); + + socket.on('data', (chunk) => { + if (closed) return; + port1.postMessage({ type: 'data', data: Uint8Array.from(chunk) }); + }); + socket.on('error', close); + socket.on('close', close); + + try { + webContents.postMessage('openchamber:relay-dev-tunnel-connect', { connectionId, remotePort: port }, [port2]); + } catch (error) { + logger.warn?.(`[dev-tunnel] failed to hand relay connection to renderer: ${error?.message || error}`); + close(); + } + }); + + const localPort = await listen(server); + server.on('error', (error) => logger.warn?.(`[dev-tunnel] relay listener failed: ${error?.message || error}`)); + tunnels.set(key, { server, connections, localPort }); + webContents.once?.('destroyed', () => closeTunnel(key)); + return { localPort, reused: false }; + }, + + closeAll() { + for (const key of [...tunnels.keys()]) closeTunnel(key); + }, + + closeForWebContents(webContentsId) { + let closed = 0; + const prefix = `${webContentsId}|`; + for (const key of [...tunnels.keys()]) { + if (!key.startsWith(prefix)) continue; + if (closeTunnel(key)) closed += 1; + } + return closed; + }, + }; +}; diff --git a/packages/electron/relay-dev-tunnel.test.mjs b/packages/electron/relay-dev-tunnel.test.mjs new file mode 100644 index 00000000..a370db82 --- /dev/null +++ b/packages/electron/relay-dev-tunnel.test.mjs @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import net from 'node:net'; +import { MessageChannel } from 'node:worker_threads'; +import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs'; + +const bridges = []; + +afterEach(() => { + while (bridges.length) bridges.pop().closeAll(); +}); + +describe('relay dev tunnel bridge', () => { + test('pipes a local browser connection through a renderer-owned message port', async () => { + let nextPort; + const webContents = { + id: 7, + isDestroyed: () => false, + once: () => {}, + postMessage: (_channel, payload, ports) => { + nextPort = ports[0]; + nextPort.on('message', (message) => { + if (message.type !== 'data') return; + expect(Buffer.from(message.data).toString()).toContain('GET /docs HTTP/1.1'); + nextPort.postMessage({ type: 'data', data: Buffer.from('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok') }); + nextPort.postMessage({ type: 'close' }); + }); + nextPort.start(); + expect(payload.remotePort).toBe(4322); + nextPort.postMessage({ type: 'ready' }); + }, + }; + const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel(), logger: { warn: () => {} } }); + bridges.push(bridge); + const { localPort } = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }); + + const response = await new Promise((resolve, reject) => { + const socket = net.connect({ host: '127.0.0.1', port: localPort }, () => socket.write('GET /docs HTTP/1.1\r\nHost: localhost\r\n\r\n')); + let data = ''; + socket.on('data', (chunk) => { data += chunk; }); + socket.on('close', () => resolve(data)); + socket.on('error', reject); + }); + expect(response).toContain('\r\n\r\nok'); + }); + + test('reuses one local listener for the same window, runtime, and port', async () => { + const webContents = { id: 9, isDestroyed: () => false, once: () => {}, postMessage: () => {} }; + const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() }); + bridges.push(bridge); + const first = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }); + const second = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }); + expect(second).toEqual({ localPort: first.localPort, reused: true }); + }); + + test('tells the renderer when the local browser connection closes', async () => { + const rendererClosed = new Promise((resolve) => { + const webContents = { + id: 11, + isDestroyed: () => false, + once: () => {}, + postMessage: (_channel, _payload, ports) => { + const rendererPort = ports[0]; + rendererPort.on('message', (message) => { + if (message.type === 'close') resolve(); + }); + rendererPort.start(); + rendererPort.postMessage({ type: 'ready' }); + }, + }; + const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() }); + bridges.push(bridge); + void bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }).then(({ localPort }) => { + const socket = net.connect({ host: '127.0.0.1', port: localPort }, () => socket.destroy()); + }); + }); + + await rendererClosed; + }); + + test('closes only listeners owned by the requested desktop window', async () => { + const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() }); + bridges.push(bridge); + const windowOne = { id: 21, isDestroyed: () => false, once: () => {}, postMessage: () => {} }; + const windowTwo = { id: 22, isDestroyed: () => false, once: () => {}, postMessage: () => {} }; + const first = await bridge.open({ targetKey: 'host:one', remotePort: 4322, webContents: windowOne }); + const second = await bridge.open({ targetKey: 'host:two', remotePort: 4322, webContents: windowTwo }); + + expect(bridge.closeForWebContents(windowOne.id)).toBe(1); + await expect(new Promise((resolve, reject) => { + const socket = net.connect({ host: '127.0.0.1', port: first.localPort }, resolve); + socket.on('error', reject); + })).rejects.toThrow(); + const remaining = await bridge.open({ targetKey: 'host:two', remotePort: 4322, webContents: windowTwo }); + expect(remaining).toEqual({ localPort: second.localPort, reused: true }); + }); +}); diff --git a/packages/ui/src/lib/browser/devTunnel.test.ts b/packages/ui/src/lib/browser/devTunnel.test.ts index ff8093a2..02bb1615 100644 --- a/packages/ui/src/lib/browser/devTunnel.test.ts +++ b/packages/ui/src/lib/browser/devTunnel.test.ts @@ -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; + close: ReturnType; + 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((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); diff --git a/packages/ui/src/lib/browser/devTunnel.ts b/packages/ui/src/lib/browser/devTunnel.ts index 439f99e2..1f9322b2 100644 --- a/packages/ui/src/lib/browser/devTunnel.ts +++ b/packages/ui/src/lib/browser/devTunnel.ts @@ -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(); /** Reverse map, so a tunnel port never leaks into the address bar or storage. */ const originByLocalPort = new Map(); +const relaySockets = new Map(); +const pendingRelayConnections = new Set(); + +const openRelayConnection = async (connectionId: string, remotePort: number): Promise => { + 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 => { 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 => { const result = await invokeDesktopCommand('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 => { 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') { diff --git a/packages/ui/src/lib/desktopNative.ts b/packages/ui/src/lib/desktopNative.ts index 9f1a7d29..945f64b0 100644 --- a/packages/ui/src/lib/desktopNative.ts +++ b/packages/ui/src/lib/desktopNative.ts @@ -1,6 +1,34 @@ import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop'; type InvokeArgs = Record; +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 ( command: string, diff --git a/packages/web/server/lib/dev-tunnel/DOCUMENTATION.md b/packages/web/server/lib/dev-tunnel/DOCUMENTATION.md index b341090c..c8d264df 100644 --- a/packages/web/server/lib/dev-tunnel/DOCUMENTATION.md +++ b/packages/web/server/lib/dev-tunnel/DOCUMENTATION.md @@ -20,7 +20,13 @@ and what made it fragile per framework. - `client.js` is the local end: it binds a loopback listener on the user's machine and pipes each accepted connection through one WebSocket. It lives in this package because it needs a WebSocket client the package already depends - on; the desktop shell drives it over IPC. + on; the desktop shell drives it over IPC for directly reachable HTTP(S) + runtimes. +- Relay-only runtimes use `packages/electron/relay-dev-tunnel.mjs` for the local + listener. Each accepted connection gets an Electron `MessagePort`; the trusted + renderer carries its bytes through the active E2EE relay. This keeps relay + credentials and encryption in their existing renderer owner instead of + duplicating them in Electron main. - Port discovery is not owned here. `runtime.js` is given the reachable set by the same dev-server discovery the user's own list is built from. - The browser panel decides when to tunnel; this module never chooses a target. @@ -40,9 +46,12 @@ and what made it fragile per framework. usual origin allowlist applies unchanged. That check is a CSRF defence: a hostile page can make a browser open a WebSocket carrying ambient cookies, and the origin is what exposes it. - - With no `Origin` the request must carry client-token auth. A browser cannot - reach this path — the WebSocket API always sends an origin and never lets a - page set an `Authorization` header — so this case is the desktop shell. + - With no `Origin` the request must carry client-token auth or a short-lived + URL token. The bearer case is the desktop main process. The URL-token case + is the trusted renderer carrying the socket through the E2EE relay. + - Through the E2EE relay, the trusted renderer mints a short-lived URL token + and includes it in the virtual WebSocket URL. The relay host and URL-token + allowlists accept exactly `/api/dev-tunnel`, not subpaths. - Concurrency is capped per host, not per page, because one page load opens many sockets. - A connection that cannot be established fails the socket rather than holding diff --git a/packages/web/server/lib/dev-tunnel/runtime.js b/packages/web/server/lib/dev-tunnel/runtime.js index efe265a2..46792f18 100644 --- a/packages/web/server/lib/dev-tunnel/runtime.js +++ b/packages/web/server/lib/dev-tunnel/runtime.js @@ -20,9 +20,9 @@ * * - With an `Origin` header, the request came from a browser context and the * usual origin check applies unchanged. - * - With no `Origin`, the request must carry client-token auth. A browser - * cannot reach this path: the WebSocket API always sends an origin and never - * lets a page set an `Authorization` header. + * - With no `Origin`, the request must carry client-token auth or a short-lived + * URL token. The URL-token case is used only by the trusted renderer through + * the E2EE relay; the UI-auth allowlist limits it to this exact path. */ import net from 'node:net'; import { WebSocketServer } from 'ws'; @@ -133,7 +133,7 @@ export function createDevTunnelRuntime({ void (async () => { try { if (uiAuthController?.enabled) { - const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: false }); + const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: true }); if (!auth) { rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); return; diff --git a/packages/web/server/lib/dev-tunnel/tunnel.test.js b/packages/web/server/lib/dev-tunnel/tunnel.test.js index 5e593891..e3ab1ba3 100644 --- a/packages/web/server/lib/dev-tunnel/tunnel.test.js +++ b/packages/web/server/lib/dev-tunnel/tunnel.test.js @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import http from 'node:http'; import net from 'node:net'; +import WebSocket from 'ws'; import { createDevTunnelClient } from './client.js'; import { createDevTunnelRuntime, isDevTunnelPath } from './runtime.js'; @@ -228,6 +229,12 @@ describe('dev tunnel authentication', () => { enabled: true, resolveAuthContext: async () => ({ type: 'session' }), }; + const urlTokenAuth = { + enabled: true, + resolveAuthContext: async (req, _res, options) => ( + options?.allowUrlToken === true && req.url.includes('oc_url_token=good') ? { type: 'client', token: 'url:authenticated' } : null + ), + }; test('accepts a bearer-authenticated client that sends no origin', async () => { const devPort = await startDevServer((_req, res) => res.end('ok')); @@ -243,6 +250,19 @@ describe('dev tunnel authentication', () => { expect((await httpGet(localPort, '/')).body).toBe('ok'); }); + test('accepts a URL-token client carried by the E2EE relay', async () => { + const devPort = await startDevServer((_req, res) => res.end('relay-ok')); + const host = await startHost({ allowedPorts: [devPort], auth: urlTokenAuth }); + + const body = await new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${host.port}/api/dev-tunnel?port=${devPort}&oc_url_token=good`); + socket.on('open', () => socket.send('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n')); + socket.on('message', (data) => resolve(Buffer.from(data).toString())); + socket.on('error', reject); + }); + expect(body).toContain('relay-ok'); + }); + test('rejects a client with no credentials', async () => { const devPort = await startDevServer((_req, res) => res.end('ok')); const host = await startHost({ allowedPorts: [devPort], auth: clientAuth }); diff --git a/packages/web/server/lib/relay/DOCUMENTATION.md b/packages/web/server/lib/relay/DOCUMENTATION.md index d6df917c..2ce5f53a 100644 --- a/packages/web/server/lib/relay/DOCUMENTATION.md +++ b/packages/web/server/lib/relay/DOCUMENTATION.md @@ -41,7 +41,7 @@ Relay is not a separate link format: it is one transport candidate inside the un Everything a client normally sends to the single OpenChamber origin: - **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`. - **SSE** — long-lived streamed responses (the event stream and notifications). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling. -- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation). +- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation, and desktop dev-server previews). The host dispatcher restricts tunneled traffic to explicit path allowlists (one for HTTP, one for WS). diff --git a/packages/web/server/lib/relay/tunnel-host.js b/packages/web/server/lib/relay/tunnel-host.js index b052b385..91c31b68 100644 --- a/packages/web/server/lib/relay/tunnel-host.js +++ b/packages/web/server/lib/relay/tunnel-host.js @@ -33,7 +33,9 @@ const ALLOWED_WS_PATHS = new Set([ '/api/event/ws', '/api/terminal/ws', '/api/dictation/ws', + '/api/dev-tunnel', ]); +export const isAllowedRelayWebSocketPath = (pathname) => ALLOWED_WS_PATHS.has(pathname); // Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch // to the loopback origin. content-length is dropped too because the body is @@ -425,7 +427,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf void sendAbort(streamId, error?.message ?? 'malformed ws open'); return; } - if (!ALLOWED_WS_PATHS.has(open.path)) { + if (!isAllowedRelayWebSocketPath(open.path)) { void sendAbort(streamId, 'Path is not allowed through the relay'); return; } diff --git a/packages/web/server/lib/relay/tunnel-host.test.js b/packages/web/server/lib/relay/tunnel-host.test.js index 382e2c55..8ef521bd 100644 --- a/packages/web/server/lib/relay/tunnel-host.test.js +++ b/packages/web/server/lib/relay/tunnel-host.test.js @@ -1,7 +1,7 @@ import { describe, test, expect } from 'bun:test'; import http from 'node:http'; -import { createTunnelHost } from './tunnel-host.js'; +import { createTunnelHost, isAllowedRelayWebSocketPath } from './tunnel-host.js'; import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js'; const startLoopback = () => @@ -145,3 +145,11 @@ describe('tunnel-host HTTP body forwarding', () => { await loopback.stop(); }); }); + +describe('relay host WebSocket allowlist', () => { + test('allows only the exact dev-server tunnel path', () => { + expect(isAllowedRelayWebSocketPath('/api/dev-tunnel')).toBe(true); + expect(isAllowedRelayWebSocketPath('/api/dev-tunnel/')).toBe(false); + expect(isAllowedRelayWebSocketPath('/api/database/ws')).toBe(false); + }); +}); diff --git a/packages/web/server/lib/ui-auth/ui-auth.js b/packages/web/server/lib/ui-auth/ui-auth.js index aa61edde..71232d6e 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.js +++ b/packages/web/server/lib/ui-auth/ui-auth.js @@ -310,6 +310,7 @@ const isUrlAuthWebSocketPath = (pathname) => { || pathname === '/api/openchamber/realtime-proxy/ws' || pathname === '/api/terminal/ws' || pathname === '/api/dictation/ws' + || pathname === '/api/dev-tunnel' || pathname.startsWith('/api/preview/proxy/'); }; diff --git a/packages/web/server/lib/ui-auth/ui-auth.test.js b/packages/web/server/lib/ui-auth/ui-auth.test.js index 185b0af1..f0d5f019 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.test.js +++ b/packages/web/server/lib/ui-auth/ui-auth.test.js @@ -221,6 +221,22 @@ describe('ui auth client credential seam', () => { }; expect(await auth.ensureSessionToken(dictationWsReq, null)).toBe('client:device-1'); + const devTunnelWsReq = { + method: 'GET', + path: '/api/dev-tunnel', + url: `/api/dev-tunnel?port=4322&oc_url_token=${encodeURIComponent(urlToken)}`, + headers: { upgrade: 'websocket' }, + }; + expect(await auth.ensureSessionToken(devTunnelWsReq, null)).toBe('client:device-1'); + + const devTunnelSubpathWsReq = { + method: 'GET', + path: '/api/dev-tunnel/private', + url: `/api/dev-tunnel/private?port=4322&oc_url_token=${encodeURIComponent(urlToken)}`, + headers: { upgrade: 'websocket' }, + }; + expect(await auth.ensureSessionToken(devTunnelSubpathWsReq, null)).toBe(null); + const dictationHttpReq = { method: 'GET', path: '/api/dictation/ws', From e8ba8e996f2ff7261a44ce41da831eeb7494bc24 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 2 Sep 2026 12:19:55 +0300 Subject: [PATCH 10/17] fix: show full desktop instance label in header --- packages/ui/src/components/layout/Header.tsx | 29 ++------------------ 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 41bc1dfa..e89c2ec1 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -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>; @@ -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' )} > {isDesktopApp ? ( - {compactCurrentInstanceLabel} + {currentInstanceLabel} ) : null} @@ -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(null); const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false); const [remoteUpdateError, setRemoteUpdateError] = React.useState(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 = () => { Date: Wed, 2 Sep 2026 17:42:17 +0300 Subject: [PATCH 11/17] fix: scope instance-served state to the connected instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear and GitHub logins, quotas, MCP status, skills and agent memory are served by whichever instance is connected, but each was cached globally or by directory alone — which two instances can share. Switching instances left the previous instance's answers on screen and its Linear login usable against a runtime that has no Linear. Reset them all through runtimeEndpointReset, each store guarding its in-flight requests with a generation so a response for the previous instance cannot land in the new one. The Linear team filter is now persisted per instance: a team belongs to one workspace, so carrying it across filtered the new instance's issue list down to nothing. Usage also waits for the instance to report itself initialised before loading. Providers report themselves as configured only once the instance can read their credentials, so a fetch fired at mount answered "nothing configured" for every provider and cached it — which is why Usage stayed missing from the work-status panel until Settings -> Usage forced a fresh fetch. --- packages/ui/src/App.tsx | 7 +- packages/ui/src/apps/runtimeEndpointReset.ts | 23 ++++ .../chat/work-status/DOCUMENTATION.md | 22 ++++ .../work-status/WorkStatusContextSection.tsx | 9 +- .../chat/work-status/WorkStatusMcpSection.tsx | 7 +- .../work-status/WorkStatusUsageSection.tsx | 19 ++-- .../src/components/views/LinearIssuesView.tsx | 8 ++ packages/ui/src/stores/DOCUMENTATION.md | 2 +- packages/ui/src/stores/useGitHubAuthStore.ts | 13 +++ .../ui/src/stores/useLinearAuthStore.test.ts | 79 ++++++++++++++ packages/ui/src/stores/useLinearAuthStore.ts | 18 ++++ packages/ui/src/stores/useMcpStore.ts | 17 +++ packages/ui/src/stores/useQuotaStore.test.ts | 100 ++++++++++++++++++ packages/ui/src/stores/useQuotaStore.ts | 67 +++++++++++- packages/ui/src/stores/useSkillsStore.ts | 12 +++ .../stores/useUIStore.linearFilters.test.ts | 50 ++++++++- packages/ui/src/stores/useUIStore.ts | 78 ++++++++++++-- .../ui/src/sync/__tests__/issue-2039.test.ts | 4 + 18 files changed, 509 insertions(+), 26 deletions(-) create mode 100644 packages/ui/src/stores/useLinearAuthStore.test.ts create mode 100644 packages/ui/src/stores/useQuotaStore.test.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index e52bcb1e..21581e28 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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(); diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index cd1f33ce..afd90a7d 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -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()); diff --git a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md index 00a1abd3..d96a6b6d 100644 --- a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md @@ -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 diff --git a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx index b549a38a..fe188fc8 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx @@ -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 = ({ 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 diff --git a/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx index d44a846e..2ac0efb0 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx @@ -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 = ({ 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(null); // The panel must not depend on the header dropdown having been mounted or @@ -35,9 +37,12 @@ export const WorkStatusMcpSection: React.FC = ({ 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)), diff --git a/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx index 3484512e..41deccbb 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx @@ -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; diff --git a/packages/ui/src/components/views/LinearIssuesView.tsx b/packages/ui/src/components/views/LinearIssuesView.tsx index 8454e81c..f6ae069b 100644 --- a/packages/ui/src/components/views/LinearIssuesView.tsx +++ b/packages/ui/src/components/views/LinearIssuesView.tsx @@ -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); diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index ac82998a..355db701 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -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 diff --git a/packages/ui/src/stores/useGitHubAuthStore.ts b/packages/ui/src/stores/useGitHubAuthStore.ts index 410267cc..2d16b773 100644 --- a/packages/ui/src/stores/useGitHubAuthStore.ts +++ b/packages/ui/src/stores/useGitHubAuthStore.ts @@ -13,6 +13,8 @@ type GitHubAuthStore = { runtimeGitHub?: RuntimeAPIs['github'], options?: { force?: boolean } ) => Promise; + /** 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 | 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((set, get) => ({ status: null, @@ -50,13 +55,16 @@ export const useGitHubAuthStore = create((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((set, get) => ({ return _inFlightAuthRefresh; }, + resetForRuntimeSwitch: () => { + authGeneration += 1; + _inFlightAuthRefresh = null; + set({ status: null, isLoading: false, hasChecked: false }); + }, })); diff --git a/packages/ui/src/stores/useLinearAuthStore.test.ts b/packages/ui/src/stores/useLinearAuthStore.test.ts new file mode 100644 index 00000000..931b129e --- /dev/null +++ b/packages/ui/src/stores/useLinearAuthStore.test.ts @@ -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 = () => { + let resolve!: (value: T) => void + const promise = new Promise((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() + 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") + }) +}) diff --git a/packages/ui/src/stores/useLinearAuthStore.ts b/packages/ui/src/stores/useLinearAuthStore.ts index 95560a2f..70aebfcc 100644 --- a/packages/ui/src/stores/useLinearAuthStore.ts +++ b/packages/ui/src/stores/useLinearAuthStore.ts @@ -12,6 +12,13 @@ type LinearAuthStore = { runtimeLinear?: RuntimeAPIs['linear'], options?: { force?: boolean } ) => Promise; + /** + * 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 | 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((set, get) => ({ status: null, @@ -41,13 +51,16 @@ export const useLinearAuthStore = create((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((set, get) => ({ return inFlightAuthRefresh; }, + resetForRuntimeSwitch: () => { + authGeneration += 1; + inFlightAuthRefresh = null; + set({ status: null, isLoading: false, hasChecked: false }); + }, })); diff --git a/packages/ui/src/stores/useMcpStore.ts b/packages/ui/src/stores/useMcpStore.ts index 915c83c1..ab4ac80a 100644 --- a/packages/ui/src/stores/useMcpStore.ts +++ b/packages/ui/src/stores/useMcpStore.ts @@ -91,6 +91,12 @@ interface McpStore { completeAuth: (name: string, code: string, directory?: string | null) => Promise; clearAuth: (name: string, directory?: string | null) => Promise; testConnection: (name: string, directory?: string | null) => Promise; + /** + * 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()( @@ -101,6 +107,17 @@ export const useMcpStore = create()( lastErrorKeys: {}, refreshedAtKeys: {}, + resetForRuntimeSwitch: () => { + ensureFreshInFlight.clear(); + set({ + byDirectory: {}, + diagnosticsByDirectory: {}, + loadingKeys: {}, + lastErrorKeys: {}, + refreshedAtKeys: {}, + }); + }, + getStatusForDirectory: (directory) => { const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory); return get().byDirectory[key] ?? EMPTY_STATUS; diff --git a/packages/ui/src/stores/useQuotaStore.test.ts b/packages/ui/src/stores/useQuotaStore.test.ts new file mode 100644 index 00000000..036d3d3f --- /dev/null +++ b/packages/ui/src/stores/useQuotaStore.test.ts @@ -0,0 +1,100 @@ +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 +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 (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 + 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) + }) +}) diff --git a/packages/ui/src/stores/useQuotaStore.ts b/packages/ui/src/stores/useQuotaStore.ts index 19667325..a6f8b396 100644 --- a/packages/ui/src/stores/useQuotaStore.ts +++ b/packages/ui/src/stores/useQuotaStore.ts @@ -8,8 +8,14 @@ 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 quotaAutoRefreshConsumers = 0; let quotaAutoRefreshInterval: number | null = null; @@ -22,6 +28,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; @@ -40,6 +48,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; + resetForRuntimeSwitch: () => void; } const parseSettings = (data: Record | null): QuotaSettingsState => { @@ -119,6 +139,7 @@ export const useQuotaStore = create()( devtools( (set, get) => ({ results: [], + loadedRuntimeKey: null, selectedProviderId: null, isLoading: false, isFetchingProvider: {}, @@ -130,8 +151,10 @@ export const useQuotaStore = create()( 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,16 +162,19 @@ export const useQuotaStore = create()( }, fetchQuotas: async (providerIds) => { + const generation = quotaGeneration; set({ isLoading: true, error: null }); try { await Promise.all( providerIds.map((providerId) => get().fetchProviderQuota(providerId)) ); + if (generation !== quotaGeneration) return; set({ isLoading: false, lastUpdated: Date.now() }); } catch (error) { + if (generation !== quotaGeneration) return; const message = error instanceof Error ? error.message : 'Failed to fetch quotas'; set({ isLoading: false, error: message }); } @@ -159,6 +185,7 @@ export const useQuotaStore = create()( }, fetchProviderQuota: async (providerId) => { + const generation = quotaGeneration; set((state) => ({ isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true } })); @@ -169,6 +196,7 @@ export const useQuotaStore = create()( throw new Error(payload?.error || 'Failed to fetch quota'); } + if (generation !== quotaGeneration) return; const result = payload as ProviderResult; set((state) => { const next = state.results.filter((entry) => entry.providerId !== providerId); @@ -176,6 +204,7 @@ export const useQuotaStore = create()( return { results: next, error: null }; }); } catch (error) { + if (generation !== quotaGeneration) return; const message = error instanceof Error ? error.message : 'Failed to fetch quota'; const fallback: ProviderResult = { providerId, @@ -192,12 +221,44 @@ export const useQuotaStore = create()( return { results: next, error: message }; }); } 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; + set({ loadedRuntimeKey: runtimeKey }); + const generation = quotaGeneration; + await get().loadSettings(); + if (generation !== quotaGeneration) return; + const { dropdownProviderIds, fetchQuotas } = get(); + if (dropdownProviderIds.length === 0) return; + await fetchQuotas(dropdownProviderIds); + }, + + resetForRuntimeSwitch: () => { + quotaGeneration += 1; + set({ + 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 }), diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index a4c6a4e2..068eae4c 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -173,6 +173,12 @@ interface SkillsStore { renameSkill: (name: string, newName: string, directory?: string | null) => Promise; deleteSkill: (name: string, directory?: string | null) => Promise; 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; @@ -279,6 +285,12 @@ export const useSkillsStore = create()( isLoading: false, skillDraft: null, + resetForRuntimeSwitch: () => { + skillsLastLoadedAt.clear(); + skillsLoadInFlight.clear(); + set({ skills: [], skillsByDirectory: {}, isLoading: false }); + }, + setSelectedSkill: (name: string | null) => { set({ selectedSkillName: name }); }, diff --git a/packages/ui/src/stores/useUIStore.linearFilters.test.ts b/packages/ui/src/stores/useUIStore.linearFilters.test.ts index 44789aa4..952f79cc 100644 --- a/packages/ui/src/stores/useUIStore.linearFilters.test.ts +++ b/packages/ui/src/stores/useUIStore.linearFilters.test.ts @@ -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' }); + }); }); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 0066881c..a5b00cf7 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -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, + teamId: string, +): Record { + 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const entries: Record = {}; + // SAFETY: guarded above as a non-array object; every value is re-checked below. + for (const [runtimeKey, teamId] of Object.entries(value as Record)) { + 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; 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()( 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()( }, 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()( }, 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()( { 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()( 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()( gitChangesViewMode: state.gitChangesViewMode, linearIssueListStatus: state.linearIssueListStatus, linearIssueListAssignee: state.linearIssueListAssignee, - linearIssueListTeamId: state.linearIssueListTeamId, + linearIssueListTeamIdByRuntime: state.linearIssueListTeamIdByRuntime, linearIssueListPriority: state.linearIssueListPriority, nativeNotificationsEnabled: state.nativeNotificationsEnabled, notificationMode: state.notificationMode, diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index 7558bac5..b6fe2b09 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -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, From 8f1e0c8b5fc3e972a431ab4782c3ceb3daafa9e6 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 2 Sep 2026 17:42:23 +0300 Subject: [PATCH 12/17] fix: retry the relay instance probe before reporting it unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, which retries for itself, but it made the 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. The probe now spans the tunnel's own reconnects within a 15s budget, and ends immediately on a terminal tunnel state (auth failed, duplicate client, limit), which waiting cannot resolve. --- packages/ui/src/lib/desktopHosts.test.ts | 106 ++++++++++++++++++++++- packages/ui/src/lib/desktopHosts.ts | 50 +++++++++-- 2 files changed, 148 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/lib/desktopHosts.test.ts b/packages/ui/src/lib/desktopHosts.test.ts index 114c8f87..5ac8d7c7 100644 --- a/packages/ui/src/lib/desktopHosts.test.ts +++ b/packages/ui/src/lib/desktopHosts.test.ts @@ -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; + 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 (handler: (cmd: string, args: Record) => unknown | Promise, run: () => Promise): Promise => { 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 (run: () => Promise): Promise => { + 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, + 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']); + }); +}); diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index ddcc4787..da846415 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -408,6 +408,9 @@ export const desktopInstallIdGet = async (): Promise => { }; 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, @@ -423,13 +426,47 @@ 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, + path: string, + deadline: number, + init?: RequestInit, +): Promise => { + for (;;) { + try { + return await fetchRelayProbe(tunnel, path, 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 +481,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 +492,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) }; } From 17fd46d4e597269e2b125fbb0e0192ab35f821cd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 2 Sep 2026 17:42:29 +0300 Subject: [PATCH 13/17] fix: keep instance statuses between switcher opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switcher held reachability in component state and replaced the whole map at the end of a probe run. It ran once per open before the config had loaded — with Local as the only host — so that pass wiped every other instance's status and each open started on "Checking", including for the instance the app was connected to and actively talking to. Statuses move to their own module: startup warms them so the switcher opens on real values, a re-probe replaces each value in place as it lands rather than blanking them first, and stale entries are dropped against the loaded config instead of a partial host list. The connected instance never reads "Checking" — the live connection already answers what the probe would ask. --- .../desktop/DesktopHostSwitcher.tsx | 95 ++++------ packages/ui/src/lib/desktopHostStatus.test.ts | 101 +++++++++++ packages/ui/src/lib/desktopHostStatus.ts | 168 ++++++++++++++++++ packages/web/src/runtimeConfig.ts | 11 ++ 4 files changed, 313 insertions(+), 62 deletions(-) create mode 100644 packages/ui/src/lib/desktopHostStatus.test.ts create mode 100644 packages/ui/src/lib/desktopHostStatus.ts diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 8cd3022c..92a34faf 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -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 = {}; +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([]); const [defaultHostId, setDefaultHostId] = React.useState(null); - const [statusById, setStatusById] = React.useState>(() => ({ ...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(null); const [sshHostIds, setSshHostIds] = React.useState>({}); @@ -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 => { - 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 = {}; - 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 diff --git a/packages/ui/src/lib/desktopHostStatus.test.ts b/packages/ui/src/lib/desktopHostStatus.test.ts new file mode 100644 index 00000000..d2fa63a9 --- /dev/null +++ b/packages/ui/src/lib/desktopHostStatus.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { DesktopHost, HostProbeResult } from './desktopHosts'; + +let probeResults: Record = {}; +const probeCalls: string[] = []; +let probeGate: Promise | 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 = []; + 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((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); + }); +}); diff --git a/packages/ui/src/lib/desktopHostStatus.ts b/packages/ui/src/lib/desktopHostStatus.ts new file mode 100644 index 00000000..303f1c5a --- /dev/null +++ b/packages/ui/src/lib/desktopHostStatus.ts @@ -0,0 +1,168 @@ +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; + +type DesktopHostStatusSnapshot = { + byHostId: Readonly; + /** 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(); +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. */ +export const setDesktopHostStatus = (hostId: string, status: DesktopHostStatus): void => { + 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); + changed = true; + } + if (changed) publishSnapshot(); +}; + +const isBlockedProbeStatus = (status: HostProbeResult['status']): boolean => + status === 'unreachable' || status === 'wrong-service' || status === 'incompatible'; + +const getLocalClientToken = async (): Promise => { + if (!isElectronShell()) return ''; + return desktopLocalClientTokenGet().catch(() => ''); +}; + +const probeHost = async (host: DesktopHost, localClientToken: string): Promise => { + const clientToken = host.id === LOCAL_HOST_ID ? localClientToken : (host.clientToken || ''); + const probeRelayLeg = async (): Promise => { + 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 => { + if (!isDesktopShell()) return; + activeProbeRuns += 1; + publishSnapshot(); + try { + const localClientToken = await getLocalClientToken(); + await Promise.all(hosts.map(async (host) => { + setStatus(host.id, await probeHost(host, localClientToken)); + })); + } 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 => { + 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]); +}; diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index 4d45efa8..d6a968f3 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -1,6 +1,7 @@ import { getRuntimeExtraHeadersSync, refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch'; import { initializeRuntimeEndpoint, switchRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; +import { warmDesktopHostStatuses } from '@openchamber/ui/lib/desktopHostStatus'; import { restoreDesktopRelayRuntime } from '@openchamber/ui/lib/desktopRelayRestore'; import { getInjectedBootOutcome } from '@openchamber/ui/lib/desktopBoot'; import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; @@ -8,6 +9,9 @@ import type { EmbeddedSessionRuntimeBootstrap } from '@openchamber/ui/components import { opencodeClient } from '@openchamber/ui/lib/opencode/client'; import { createWebAPIs } from './api'; +// The switcher's statuses are warmed after boot settles, not during it. +const HOST_STATUS_WARMUP_DELAY_MS = 3_000; + const sameOrigin = (left: string, right: string): boolean => { if (!left || !right) return false; try { @@ -93,5 +97,12 @@ export const createConfiguredWebAPIs = (bootstrap?: EmbeddedSessionRuntimeBootst // subscribes to runtime-change events, so bind the SDK explicitly. opencodeClient.reconnectToRuntimeBaseUrl(); }); + // Learn every instance's reachability in the background, so the switcher opens + // on real values instead of probing for the first time under the user's + // cursor. After the endpoint is settled and past the app's own bootstrap: + // this is unprompted work and the machine's network is busiest at launch. + void desktopRelayRestoreReady.then(() => { + window.setTimeout(() => { void warmDesktopHostStatuses().catch(() => {}); }, HOST_STATUS_WARMUP_DELAY_MS); + }); return createWebAPIs({ urls }); }; From f0ced9d3618300054dfad813655b187399528328 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 3 Sep 2026 00:21:04 +0300 Subject: [PATCH 14/17] fix: reject instance-served responses that outlive their instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP status and skills were cleared on a switch but their in-flight requests were not, so a response for the previous instance could still write itself over the new one's — the same race the quota store already guards. Both now carry a generation. Usage also claimed an instance as loaded before it had answered, so a load that failed on a cold or briefly unreachable instance was never attempted again, and the previous instance's display mode and provider selection survived a switch — which decided what the new instance was even asked for. --- .../src/stores/instanceScopedStores.test.ts | 92 +++++++++++++++++++ packages/ui/src/stores/useMcpStore.ts | 8 ++ packages/ui/src/stores/useQuotaStore.test.ts | 39 ++++++++ packages/ui/src/stores/useQuotaStore.ts | 63 +++++++++---- packages/ui/src/stores/useSkillsStore.ts | 8 ++ 5 files changed, 191 insertions(+), 19 deletions(-) create mode 100644 packages/ui/src/stores/instanceScopedStores.test.ts diff --git a/packages/ui/src/stores/instanceScopedStores.test.ts b/packages/ui/src/stores/instanceScopedStores.test.ts new file mode 100644 index 00000000..294cf6ef --- /dev/null +++ b/packages/ui/src/stores/instanceScopedStores.test.ts @@ -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 = { promise: Promise; resolve: (value: T) => void }; +const deferred = (): Deferred => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +}; + +type McpStatusResult = Awaited['mcp']['status']>>; +let mcpStatusResponse: Deferred = 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; +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 = 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); + }); +}); diff --git a/packages/ui/src/stores/useMcpStore.ts b/packages/ui/src/stores/useMcpStore.ts index ab4ac80a..2946129e 100644 --- a/packages/ui/src/stores/useMcpStore.ts +++ b/packages/ui/src/stores/useMcpStore.ts @@ -54,6 +54,10 @@ type RefreshOptions = { }; const ensureFreshInFlight = new Map>(); +// 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; @@ -108,6 +112,7 @@ export const useMcpStore = create()( refreshedAtKeys: {}, resetForRuntimeSwitch: () => { + mcpGeneration += 1; ensureFreshInFlight.clear(); set({ byDirectory: {}, @@ -144,9 +149,11 @@ export const useMcpStore = create()( })); } + 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) => ({ @@ -162,6 +169,7 @@ export const useMcpStore = create()( 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 }, diff --git a/packages/ui/src/stores/useQuotaStore.test.ts b/packages/ui/src/stores/useQuotaStore.test.ts index 036d3d3f..81d2d4d9 100644 --- a/packages/ui/src/stores/useQuotaStore.test.ts +++ b/packages/ui/src/stores/useQuotaStore.test.ts @@ -6,6 +6,7 @@ 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" } }, @@ -22,6 +23,7 @@ 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 }) }, @@ -40,6 +42,7 @@ describe("Usage quotas are loaded once per ready instance", () => { runtimeKey = "url:https://instance-a" isInitialized = true fetched.length = 0 + quotaRequestsFail = false useQuotaStore.getState().resetForRuntimeSwitch() }) @@ -97,4 +100,40 @@ describe("Usage quotas are loaded once per ready instance", () => { 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") + }) }) diff --git a/packages/ui/src/stores/useQuotaStore.ts b/packages/ui/src/stores/useQuotaStore.ts index a6f8b396..934231e4 100644 --- a/packages/ui/src/stores/useQuotaStore.ts +++ b/packages/ui/src/stores/useQuotaStore.ts @@ -16,6 +16,7 @@ const QUOTA_REFRESH_INTERVAL_MS = 3 * 60 * 1000; // 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 | null = null; let quotaAutoRefreshConsumers = 0; let quotaAutoRefreshInterval: number | null = null; @@ -38,8 +39,10 @@ interface QuotaStore extends QuotaSettingsState { loadSettings: () => Promise; fetchAllQuotas: () => Promise; - fetchQuotas: (providerIds: QuotaProviderId[]) => Promise; - fetchProviderQuota: (providerId: QuotaProviderId) => Promise; + /** Resolves true when at least one provider answered — see `ensureLoadedForRuntime`. */ + fetchQuotas: (providerIds: QuotaProviderId[]) => Promise; + /** Resolves true when the instance answered, false on a transport failure. */ + fetchProviderQuota: (providerId: QuotaProviderId) => Promise; setSelectedProvider: (providerId: QuotaProviderId | null) => void; setDisplayMode: (mode: 'usage' | 'remaining') => void; setDropdownProviderIds: (providerIds: QuotaProviderId[]) => void; @@ -104,6 +107,13 @@ const parseSettings = (data: Record | null): QuotaSettingsState }; }; +const defaultQuotaSettings = (): QuotaSettingsState => ({ + displayMode: 'usage', + dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id), + selectedModels: {}, + expandedFamilies: {}, +}); + const loadSettingsFromRuntime = async (): Promise => { const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; if (runtimeSettings) { @@ -127,12 +137,7 @@ const loadSettingsFromRuntime = async (): Promise => { } } - return { - displayMode: 'usage', - dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id), - selectedModels: {}, - expandedFamilies: {}, - }; + return defaultQuotaSettings(); }; export const useQuotaStore = create()( @@ -165,18 +170,20 @@ export const useQuotaStore = create()( 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; + if (generation !== quotaGeneration) return false; set({ isLoading: false, lastUpdated: Date.now() }); + return answered.some(Boolean); } catch (error) { - if (generation !== quotaGeneration) return; + if (generation !== quotaGeneration) return false; const message = error instanceof Error ? error.message : 'Failed to fetch quotas'; set({ isLoading: false, error: message }); + return false; } }, @@ -196,15 +203,16 @@ export const useQuotaStore = create()( throw new Error(payload?.error || 'Failed to fetch quota'); } - if (generation !== quotaGeneration) return; + 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; + if (generation !== quotaGeneration) return false; const message = error instanceof Error ? error.message : 'Failed to fetch quota'; const fallback: ProviderResult = { providerId, @@ -220,6 +228,7 @@ export const useQuotaStore = create()( next.push(fallback); return { results: next, error: message }; }); + return false; } finally { if (generation === quotaGeneration) { set((state) => ({ @@ -237,18 +246,34 @@ export const useQuotaStore = create()( // then cached as if it were the answer. if (!useConfigStore.getState().isInitialized) return; if (get().loadedRuntimeKey === runtimeKey) return; - set({ loadedRuntimeKey: runtimeKey }); + if (inFlightRuntimeLoad) return inFlightRuntimeLoad; + const generation = quotaGeneration; - await get().loadSettings(); - if (generation !== quotaGeneration) return; - const { dropdownProviderIds, fetchQuotas } = get(); - if (dropdownProviderIds.length === 0) return; - await fetchQuotas(dropdownProviderIds); + 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, diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index 068eae4c..eee64ca5 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -198,6 +198,10 @@ const SKILLS_LOAD_CACHE_TTL_MS = 5000; const DEFAULT_SKILLS_CACHE_KEY = '__default__'; const skillsLastLoadedAt = new Map(); const skillsLoadInFlight = new Map>(); +// 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; @@ -286,6 +290,7 @@ export const useSkillsStore = create()( skillDraft: null, resetForRuntimeSwitch: () => { + skillsGeneration += 1; skillsLastLoadedAt.clear(); skillsLoadInFlight.clear(); set({ skills: [], skillsByDirectory: {}, isLoading: false }); @@ -316,6 +321,7 @@ export const useSkillsStore = create()( return inFlight; } + const generation = skillsGeneration; const request = (async () => { set({ isLoading: true }); // Failure must never look like an empty project. The mirror is the @@ -361,6 +367,7 @@ export const useSkillsStore = create()( data.externalSkills ?? null, ); + if (generation !== skillsGeneration) return false; set((state) => { const next: Partial = { skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills }, @@ -379,6 +386,7 @@ export const useSkillsStore = create()( } console.error("Failed to load skills:", lastError); + if (generation !== skillsGeneration) return false; set((state) => { const next: Partial = { skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills }, From 0107abb32dd630f23a4415a021003b801c234a8b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 3 Sep 2026 00:21:04 +0300 Subject: [PATCH 15/17] fix: order concurrent instance probes and honour the probe budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Startup warm-up, opening the switcher and the refresh button can all probe at once, and a relay host working through tunnel retries takes an order of magnitude longer than a loopback one — so a slow older run landed last and replaced a fresh "ok" with its own stale "unreachable". Each host now records which run owns its status; a status from the switch flow outranks any probe still running for it. Each relay attempt is also capped by what is left of the 15s budget rather than the full per-request timeout, so an attempt started just under the deadline can no longer run the whole 8s past it. --- packages/ui/src/lib/desktopHostStatus.test.ts | 37 +++++++++++++++++++ packages/ui/src/lib/desktopHostStatus.ts | 22 ++++++++++- packages/ui/src/lib/desktopHosts.ts | 10 ++++- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/lib/desktopHostStatus.test.ts b/packages/ui/src/lib/desktopHostStatus.test.ts index d2fa63a9..f609b27f 100644 --- a/packages/ui/src/lib/desktopHostStatus.test.ts +++ b/packages/ui/src/lib/desktopHostStatus.test.ts @@ -98,4 +98,41 @@ describe('desktop host statuses', () => { 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((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((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'); + }); }); diff --git a/packages/ui/src/lib/desktopHostStatus.ts b/packages/ui/src/lib/desktopHostStatus.ts index 303f1c5a..2fb3c9f7 100644 --- a/packages/ui/src/lib/desktopHostStatus.ts +++ b/packages/ui/src/lib/desktopHostStatus.ts @@ -40,6 +40,13 @@ type DesktopHostStatusSnapshot = { * them first. */ const statuses = new Map(); +// 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(); let activeProbeRuns = 0; let snapshot: DesktopHostStatusSnapshot = { byHostId: {}, isProbing: false }; const listeners = new Set<() => void>(); @@ -69,8 +76,13 @@ const setStatus = (hostId: string, status: DesktopHostStatus): void => { publishSnapshot(); }; -/** Record a status learned outside a probe run — the switch flow probes too. */ +/** + * 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); }; @@ -85,6 +97,7 @@ export const pruneDesktopHostStatuses = (configuredHostIds: readonly string[]): for (const hostId of Array.from(statuses.keys())) { if (keep.has(hostId)) continue; statuses.delete(hostId); + owningRunByHostId.delete(hostId); changed = true; } if (changed) publishSnapshot(); @@ -134,12 +147,17 @@ const probeHost = async (host: DesktopHost, localClientToken: string): Promise => { 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) => { - setStatus(host.id, await probeHost(host, localClientToken)); + 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; diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index da846415..ef15adf5 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -415,10 +415,11 @@ const RELAY_PROBE_RETRY_DELAY_MS = 400; const fetchRelayProbe = async ( tunnel: ReturnType, path: string, + timeoutMs: number, init?: RequestInit, ): Promise => { 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 { @@ -448,8 +449,13 @@ const fetchRelayProbeUntilDeadline = async ( init?: RequestInit, ): Promise => { 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, init); + 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; From 76128b615ff1950ce92156dd28bccbf7dfae37ae Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 3 Sep 2026 02:56:50 +0300 Subject: [PATCH 16/17] feat: add Fixel font support Adds Fixel Text as a new UI font option Allows font sources to load from direct URLs Keeps existing fontsource-based loading working --- packages/ui/src/lib/fontLoader.ts | 4 ++++ packages/ui/src/lib/fontOptions.ts | 32 ++++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/lib/fontLoader.ts b/packages/ui/src/lib/fontLoader.ts index 9df09c25..5bb95511 100644 --- a/packages/ui/src/lib/fontLoader.ts +++ b/packages/ui/src/lib/fontLoader.ts @@ -4,6 +4,10 @@ const loadedFaces = new Set(); const pendingFaces = new Map>(); 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`; }; diff --git a/packages/ui/src/lib/fontOptions.ts b/packages/ui/src/lib/fontOptions.ts index 6387698a..372b51a3 100644 --- a/packages/ui/src/lib/fontOptions.ts +++ b/packages/ui/src/lib/fontOptions.ts @@ -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; +} + +export type FontFaceSource = FontsourceFaceSource | DirectFontFaceSource; + export interface FontOptionDefinition { id: T; label: string; @@ -26,6 +35,21 @@ export const UI_FONT_OPTIONS: FontOptionDefinition[] = [ 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', From 85bf0a99de988aa08d3997d2b65f1bfac49cfd12 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 3 Sep 2026 11:45:18 +0300 Subject: [PATCH 17/17] fix: identify OpenCode Go requests by session --- packages/ui/src/lib/smallModel.ts | 1 + packages/vscode/src/opencodeGoQuota.ts | 2 +- packages/vscode/src/quotaProviders.test.ts | 1 + .../web/server/lib/quota/DOCUMENTATION.md | 2 +- .../server/lib/quota/providers/opencode-go.js | 1 + .../lib/quota/providers/opencode-go.test.js | 6 ++++- .../web/server/lib/session-assist/runtime.js | 1 + .../web/server/lib/session-goal/create.js | 4 +++- .../web/server/lib/session-goal/runtime.js | 1 + .../server/lib/small-model/DOCUMENTATION.md | 4 ++++ packages/web/server/lib/small-model/call.js | 10 ++++++-- .../web/server/lib/small-model/call.test.js | 23 +++++++++++++++++++ packages/web/server/lib/small-model/index.js | 3 ++- packages/web/server/lib/small-model/routes.js | 3 ++- packages/web/server/lib/walkthrough/index.js | 1 + 15 files changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/lib/smallModel.ts b/packages/ui/src/lib/smallModel.ts index 5eb38fab..e701f395 100644 --- a/packages/ui/src/lib/smallModel.ts +++ b/packages/ui/src/lib/smallModel.ts @@ -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 } : {}), diff --git a/packages/vscode/src/opencodeGoQuota.ts b/packages/vscode/src/opencodeGoQuota.ts index eb9739c2..616d736b 100644 --- a/packages/vscode/src/opencodeGoQuota.ts +++ b/packages/vscode/src/opencodeGoQuota.ts @@ -11,7 +11,7 @@ const toWindow = (usedPercent: number, resetAt: string) => ({ }); export const fetchOpenCodeGoUsage = async (credential: OpenCodeGoCredential) => { - const response = await fetch('https://opencode.ai/zen/go/v1/usage', { headers: { Accept: 'application/json', Authorization: `Bearer ${credential.apiKey}` }, signal: AbortSignal.timeout(15_000) }); + const response = await fetch('https://opencode.ai/zen/go/v1/usage', { headers: { Accept: 'application/json', Authorization: `Bearer ${credential.apiKey}`, 'x-opencode-session': 'openchamber-usage' }, signal: AbortSignal.timeout(15_000) }); if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) throw new Error('OpenCode Go authentication failed'); if (!response.ok) throw new Error(`OpenCode Go usage API returned HTTP ${response.status}`); const payload = await response.json().catch(() => null) as { usage?: Record } | null; diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 783583a5..62a8f014 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -99,6 +99,7 @@ describe('OpenCode Go quota provider (VS Code parity)', () => { assert.equal(result.ok, true); assert.equal((request?.headers as Record).Authorization, 'Bearer test-token'); + assert.equal((request?.headers as Record)['x-opencode-session'], 'openchamber-usage'); assert.equal(result.usage!.windows['5h']!.usedPercent, 25); assert.throws(() => fs.statSync(legacyPath)); }); diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index 7158d7f4..6cc0b45a 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -52,7 +52,7 @@ All providers should return results via shared helpers to preserve API shape: Provider modules must export `providerId`, `providerName`, `aliases`, `isConfigured(auth?)`, and `fetchQuota()`. `fetchQuota()` should return a quota result with `usage.windows` keyed by window name (for example `5h`, `7d`, `daily`) and optional provider-specific `usage.models` data. -exe.dev, Ollama Cloud, and Cursor credentials are explicitly managed through Settings. exe.dev usage uses a separately generated HTTPS API token restricted to `billing credits usage` and aggregates every `exe-*` model provider into one monthly credit window. Generate the token with `ssh exe.dev "ssh-key generate-api-key --label=openchamber --exp=30d --cmds='billing credits usage'"`. OpenCode Go usage uses `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` API key from OpenCode `auth.json` as a bearer token. The server validates managed credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database. +exe.dev, Ollama Cloud, and Cursor credentials are explicitly managed through Settings. exe.dev usage uses a separately generated HTTPS API token restricted to `billing credits usage` and aggregates every `exe-*` model provider into one monthly credit window. Generate the token with `ssh exe.dev "ssh-key generate-api-key --label=openchamber --exp=30d --cmds='billing credits usage'"`. OpenCode Go usage uses `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` API key from OpenCode `auth.json` as a bearer token and the stable `x-opencode-session: openchamber-usage` workload id. The server validates managed credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database. Command Code usage resolves account scope through `GET /alpha/whoami`, then reads server-backed credit balances and five-hour/weekly limits from `GET /alpha/billing/credits?orgId=...`. Personal accounts return `org: null` and use `/alpha/billing/credits` without an `orgId`; organization accounts include their organization id. Web/Electron and VS Code read the standard `command-code` OpenCode auth entry (including OAuth `access`) or `COMMAND_CODE_API_KEY`; credentials remain in the owning runtime and are never returned to shared UI. diff --git a/packages/web/server/lib/quota/providers/opencode-go.js b/packages/web/server/lib/quota/providers/opencode-go.js index 2642cf26..b6b97370 100644 --- a/packages/web/server/lib/quota/providers/opencode-go.js +++ b/packages/web/server/lib/quota/providers/opencode-go.js @@ -37,6 +37,7 @@ export const fetchOpenCodeGoUsage = async (apiKey, fetchImpl = fetch) => { headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}`, + 'x-opencode-session': 'openchamber-usage', 'User-Agent': 'OpenChamber quota provider', }, signal: AbortSignal.timeout(15_000), diff --git a/packages/web/server/lib/quota/providers/opencode-go.test.js b/packages/web/server/lib/quota/providers/opencode-go.test.js index 1bae1eb0..505cbee5 100644 --- a/packages/web/server/lib/quota/providers/opencode-go.test.js +++ b/packages/web/server/lib/quota/providers/opencode-go.test.js @@ -43,7 +43,11 @@ describe('OpenCode Go quota provider', () => { return new Response(JSON.stringify({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' } } })); }); expect(request.url).toBe('https://opencode.ai/zen/go/v1/usage'); - expect(request.options.headers).toMatchObject({ Accept: 'application/json', Authorization: 'Bearer secret' }); + expect(request.options.headers).toMatchObject({ + Accept: 'application/json', + Authorization: 'Bearer secret', + 'x-opencode-session': 'openchamber-usage', + }); expect(request.options.headers.Cookie).toBeUndefined(); expect(usage['5h'].usedPercent).toBe(25); }); diff --git a/packages/web/server/lib/session-assist/runtime.js b/packages/web/server/lib/session-assist/runtime.js index ab4b1f45..c479011d 100644 --- a/packages/web/server/lib/session-assist/runtime.js +++ b/packages/web/server/lib/session-assist/runtime.js @@ -257,6 +257,7 @@ export const createSessionAssistRuntime = ({ prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite ${requestedFields} in the SAME language as this sample from the conversation: "${languageSample}"`, system: buildAssistSystemPrompt(targets), directory, + sessionID: sessionId, preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined, preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined, }); diff --git a/packages/web/server/lib/session-goal/create.js b/packages/web/server/lib/session-goal/create.js index 2255f9dc..73fad9c3 100644 --- a/packages/web/server/lib/session-goal/create.js +++ b/packages/web/server/lib/session-goal/create.js @@ -14,7 +14,7 @@ export const buildGoalIntroText = (tokenBudget) => { + '\n'; }; -const fitObjective = async ({ objective, directory, providerID, modelID, warn }) => { +const fitObjective = async ({ objective, directory, sessionID, providerID, modelID, warn }) => { if (objective.length <= GOAL_OBJECTIVE_CHAR_LIMIT) return objective; let distilled = null; @@ -32,6 +32,7 @@ const fitObjective = async ({ objective, directory, providerID, modelID, warn }) 'Write in the same language as the task text.', ].join('\n'), directory, + sessionID, preferredProviderID: providerID, preferredModelID: modelID, }); @@ -66,6 +67,7 @@ export const createSessionGoal = async ({ const objectiveText = await fitObjective({ objective: String(objective ?? '').trim(), directory, + sessionID, providerID, modelID, warn, diff --git a/packages/web/server/lib/session-goal/runtime.js b/packages/web/server/lib/session-goal/runtime.js index f97af98d..89c21256 100644 --- a/packages/web/server/lib/session-goal/runtime.js +++ b/packages/web/server/lib/session-goal/runtime.js @@ -378,6 +378,7 @@ export const createSessionGoalRuntime = ({ prompt: `The goal objective:\n\n\n${goal.objective}\n\n\nThe agent's latest turn:\n\n${assistantText}\n\nReturn the verdict JSON. Write the note in the SAME language as this sample from the objective: "${goal.objective.slice(0, 200).replace(/\s+/g, ' ').trim()}"`, system: buildAuditSystemPrompt(), directory, + sessionID: typeof lastAssistantInfo?.sessionID === 'string' ? lastAssistantInfo.sessionID : undefined, preferredProviderID: typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : undefined, preferredModelID: typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : undefined, }); diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index baee6280..cb4a2b07 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -91,6 +91,10 @@ other runtime API. a blocker instead of a raw 500 message. - `call.js` — wire formats and per-provider auth, replicating OpenCode's plugin auth loaders: + - OpenCode-hosted providers receive `x-opencode-session`. Session-backed + features reuse the real OpenCode session id, walkthrough retries reuse the + walkthrough cache key, and standalone one-shot actions receive a fresh + opaque id for that generation. - **GitHub Copilot**: fetches the requested model's authenticated `/models` metadata from `https://api.githubcopilot.com` (or `copilot-api.`) and honors its advertised endpoint, preferring diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index 9a7d4e95..7703aa74 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -1,6 +1,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { randomUUID } from 'node:crypto'; import { readAuthFile, writeAuthFile } from '../opencode/auth.js'; import { readConfig, readConfigLayers, isPlainObject } from '../opencode/shared.js'; import { getCatalogProvider } from './catalog.js'; @@ -645,7 +646,7 @@ export async function resolveProviderLogin({ auth, workingDirectory, providerID || null; } -export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) { +export async function callSmallModel({ auth, catalog, workingDirectory, sessionID, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) { const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS; const providerConfig = readProviderConfig(workingDirectory, providerID); const runtimeProvider = await getRuntimeProvider(providerID); @@ -795,7 +796,12 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider baseURL, // Configured headers last: a gateway that authenticates on its own header // must be able to override the bearer default rather than sit beside it. - headers: mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers), + headers: mergeHeadersCaseInsensitive( + mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers), + providerID.startsWith('opencode') + ? { 'x-opencode-session': typeof sessionID === 'string' && sessionID.trim() ? sessionID.trim() : randomUUID() } + : null, + ), modelID, prompt, system, diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 7c2a5315..31456f0d 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -483,6 +483,29 @@ describe('callSmallModel — custom provider config', () => { }); describe('catalog-based base URL (no config override)', () => { + it('identifies OpenCode Go requests with the owning conversation', async () => { + readConfig.mockReturnValue({}); + fetchMock.mockResolvedValue(ok('ok')); + + await callSmallModel({ + auth: { 'opencode-go': { type: 'api', key: 'go-key' } }, + catalog: { + 'opencode-go': { + id: 'opencode-go', + api: 'https://opencode.ai/zen/go/v1', + models: { utility: { id: 'utility' } }, + }, + }, + workingDirectory: '/proj', + sessionID: 'ses_conversation', + providerID: 'opencode-go', + modelID: 'utility', + prompt: 'hi', + }); + + expect(lastCall(fetchMock).init.headers['x-opencode-session']).toBe('ses_conversation'); + }); + it('uses the catalog api field when no config baseURL is set', async () => { readConfig.mockReturnValue({}); fetchMock.mockResolvedValue(ok('ok')); diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js index 667eb526..e59c0fbf 100644 --- a/packages/web/server/lib/small-model/index.js +++ b/packages/web/server/lib/small-model/index.js @@ -102,7 +102,7 @@ const readConfiguredSmallModel = (workingDirectory) => { * Generates text with the user's small model, resolved and authenticated * entirely server-side from the OpenCode config and auth store. */ -export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) { +export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) { if (typeof prompt !== 'string' || !prompt.trim()) { throw Object.assign(new Error('prompt is required'), { statusCode: 400 }); } @@ -169,6 +169,7 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens, auth, catalog, workingDirectory: directory, + sessionID, providerID: resolved.providerID, modelID: resolved.modelID, prompt: clamped.prompt, diff --git a/packages/web/server/lib/small-model/routes.js b/packages/web/server/lib/small-model/routes.js index 461e0992..9431ff2f 100644 --- a/packages/web/server/lib/small-model/routes.js +++ b/packages/web/server/lib/small-model/routes.js @@ -21,13 +21,14 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) { app.post('/api/small-model/generate', async (req, res) => { try { const { generateSmallModelText } = await getSmallModelService(); - const { prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {}; + const { prompt, system, maxOutputTokens, model, directory, sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {}; const result = await generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, + sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider: restrictToPreferredProvider === true, diff --git a/packages/web/server/lib/walkthrough/index.js b/packages/web/server/lib/walkthrough/index.js index 601ca0dc..38ac5a56 100644 --- a/packages/web/server/lib/walkthrough/index.js +++ b/packages/web/server/lib/walkthrough/index.js @@ -474,6 +474,7 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit prompt: options.prompt, system: options.system, directory, + sessionID: `openchamber-walkthrough-${cacheKey}`, model: `${model.providerID}/${model.modelID}`, responseSchema: options.responseSchema, onOverflow: 'error',