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

This commit is contained in:
Bohdan Triapitsyn
2026-09-03 12:41:59 +03:00
141 changed files with 3435 additions and 286 deletions
@@ -2584,6 +2584,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
selectedDraftDirectory,
selectedDraftBranchLabel,
selectedDraftBranchIsKnown,
selectedDraftDirectoryHasUncommittedChanges,
projectRootBranchOption,
worktreeBranchOptions,
draftBranchItems,
@@ -2851,6 +2852,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
selectedDirectory={selectedDraftDirectory}
selectedBranchLabel={selectedDraftBranchLabel}
selectedBranchIsKnown={selectedDraftBranchIsKnown}
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
projectRootBranchOption={projectRootBranchOption}
worktreeBranchOptions={worktreeBranchOptions}
branchItems={draftBranchItems}
@@ -2865,6 +2867,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
<MobileDraftTargetTriggers
selectedProject={selectedDraftProject}
selectedBranchLabel={selectedDraftBranchLabel}
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
showBranchSelector={shouldShowDraftBranchSelector}
theme={currentTheme}
onOpenPicker={setMobileDraftPicker}
@@ -3276,6 +3279,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
selectedDirectory={selectedDraftDirectory}
selectedBranchLabel={selectedDraftBranchLabel}
selectedBranchIsKnown={selectedDraftBranchIsKnown}
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
projectRootBranchOption={projectRootBranchOption}
worktreeBranchOptions={worktreeBranchOptions}
branchItems={draftBranchItems}
@@ -169,7 +169,9 @@ and the send path reading the same grammar.
recorded before a queued write could resurrect it.
- `state/useDraftTarget.ts` — the draft can target a directory that does not
exist yet (a worktree being created). It must survive not appearing in the
branch list, or the selector snaps back to the project root mid-creation.
branch list, or the selector snaps back to the project root mid-creation. It
also owns the advisory dirty state for the selected directory, clearing it as
soon as the target changes so a warning never names a previous branch.
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
state and registers its application shortcuts locally. The selectors only
consume their shared prefix while the draft target UI is mounted.
@@ -25,6 +25,7 @@ import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
import { normalizePath } from '../attachments/filePaths';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
import { useI18n } from '@/lib/i18n';
import { getGitStatus } from '@/lib/gitApi';
/** How long a cached branch list is served before it is refreshed. */
const BRANCHES_SWR_TTL_MS = 30_000;
@@ -98,6 +99,7 @@ export function useDraftTarget(enabled: boolean) {
const hasDraftBranchList = Boolean(selectedDraftProjectBranches?.all);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false);
const [dirtyDraftDirectory, setDirtyDraftDirectory] = React.useState<string | null>(null);
React.useEffect(() => {
if (!enabled || !selectedDraftProjectPath || !runtimeGit || selectedDraftProjectIsGitRepo !== null) {
@@ -189,6 +191,35 @@ export function useDraftTarget(enabled: boolean) {
[newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath],
);
React.useEffect(() => {
if (
!enabled
|| !selectedDraftDirectory
|| selectedDraftProject?.kind === 'chat'
|| newSessionDraft?.pendingWorktreeRequestId
|| newSessionDraft?.bootstrapPendingDirectory
) {
setDirtyDraftDirectory(null);
return;
}
let cancelled = false;
setDirtyDraftDirectory(null);
getGitStatus(selectedDraftDirectory, { mode: 'light' })
.then((status) => {
if (!cancelled && (status.files?.length ?? 0) > 0) {
setDirtyDraftDirectory(selectedDraftDirectory);
}
})
.catch(() => {
if (!cancelled) setDirtyDraftDirectory(null);
});
return () => {
cancelled = true;
};
}, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftProject?.kind]);
const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => {
const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
return Boolean(
@@ -306,6 +337,7 @@ export function useDraftTarget(enabled: boolean) {
selectedDraftDirectory,
selectedDraftBranchLabel,
selectedDraftBranchIsKnown,
selectedDraftDirectoryHasUncommittedChanges: dirtyDraftDirectory === selectedDraftDirectory,
projectRootBranchOption,
worktreeBranchOptions,
draftBranchItems,
@@ -12,6 +12,7 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
import {
Select,
@@ -44,6 +45,7 @@ export interface DraftTargetProps {
selectedDirectory: string | null;
selectedBranchLabel: string | null;
selectedBranchIsKnown: boolean;
hasUncommittedChanges: boolean;
projectRootBranchOption: BranchOption | null;
worktreeBranchOptions: readonly BranchOption[];
branchItems: readonly BranchOption[];
@@ -92,14 +94,39 @@ function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme:
}
/** Desktop: inline project and branch selects. */
/** How long the dirty-directory tooltip announces itself before becoming hover-only. */
const DIRTY_TOOLTIP_FLASH_MS = 5000;
/**
* Opens the tooltip for a few seconds when the dirty state first appears, so
* the warning is seen without hovering, then hands control back to hover.
*/
function useDirtyFlashTooltip(hasUncommittedChanges: boolean) {
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
if (!hasUncommittedChanges) {
setOpen(false);
return;
}
setOpen(true);
const timer = window.setTimeout(() => setOpen(false), DIRTY_TOOLTIP_FLASH_MS);
return () => window.clearTimeout(timer);
}, [hasUncommittedChanges]);
return { open, onOpenChange: setOpen };
}
export function DraftTargetSelectors(props: DraftTargetProps) {
const { t } = useI18n();
const dirtyTooltip = useDirtyFlashTooltip(props.hasUncommittedChanges);
const {
projects,
selectedProject,
selectedDirectory,
selectedBranchLabel,
selectedBranchIsKnown,
hasUncommittedChanges,
projectRootBranchOption,
worktreeBranchOptions,
branchItems,
@@ -176,16 +203,32 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
onValueChange={handleDirectoryChange}
disableGlobalShortcuts
>
<SelectTrigger
ref={worktreeTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
<SelectValue>
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
<TooltipTrigger asChild>
<SelectTrigger
ref={worktreeTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
{hasUncommittedChanges ? (
<Icon
name="alert"
className="size-3.5 shrink-0 text-[var(--status-warning)]"
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
/>
) : null}
<SelectValue>
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
</TooltipTrigger>
{hasUncommittedChanges ? (
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
</TooltipContent>
) : null}
</Tooltip>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
{projectRootBranchOption ? (
<SelectGroup>
@@ -228,11 +271,12 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
/** Mobile: buttons that open the bottom sheets below. */
export function MobileDraftTargetTriggers(
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'theme'>
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'hasUncommittedChanges' | 'theme'>
& { onOpenPicker: (picker: 'project' | 'branch') => void },
) {
const { t } = useI18n();
const { selectedProject, selectedBranchLabel, showBranchSelector, theme, onOpenPicker } = props;
const { selectedProject, selectedBranchLabel, showBranchSelector, hasUncommittedChanges, theme, onOpenPicker } = props;
const dirtyTooltip = useDirtyFlashTooltip(hasUncommittedChanges);
return (
<div className="mb-1.5 flex min-w-0 items-center gap-x-2 px-0.5">
@@ -247,14 +291,30 @@ export function MobileDraftTargetTriggers(
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
{showBranchSelector ? (
<button
type="button"
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('branch')}
>
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('branch')}
>
{hasUncommittedChanges ? (
<Icon
name="alert"
className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]"
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
/>
) : null}
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
</TooltipTrigger>
{hasUncommittedChanges ? (
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
</TooltipContent>
) : null}
</Tooltip>
) : null}
</div>
);
@@ -347,6 +347,28 @@ the matching header dropdown:
discovered relative to the active project. It does not wrap the call in
`runBackgroundNetworkTask`: the store already gates its own fetch.
Usage waits for the instance to say it is initialised. Quota providers report
themselves as configured only once the instance can read their credentials,
which on a remote instance is not true when the UI mounts — a fetch fired at
mount gets "nothing configured" for every provider, and since each one then has
a result, nothing asks again until the three-minute refresh. That is why Usage
could stay missing from the panel until Settings -> Usage forced a fresh fetch.
`useQuotaStore.ensureLoadedForRuntime` owns both the readiness rule and the
once-per-instance bookkeeping, so every caller can ask on each connection
change.
### These readouts belong to the connected instance
Quotas, MCP status, skills, agent memory and the Linear/GitHub logins are all
served by whichever OpenChamber instance is connected, and each was cached
globally or by directory alone — which two instances can share. A switch left
the previous instance's answers on screen, and its Linear login usable against
a runtime that has no Linear. `apps/runtimeEndpointReset.ts` now drops all of
them, each store guarding its own in-flight requests with a generation so a
response for the previous instance cannot land in the new one. The MCP and
skills effects take `isConnected` as a dependency — not a gate — because
`directory` alone does not change when both instances hold the same path.
The panel now performs these itself, silently and through the
background-network gate, so it cannot compete with chat bootstrap traffic for
sockets. Usage additionally provides an explicit refresh action in its section
@@ -15,6 +15,7 @@ import { resolveProjectContextId } from '@/lib/projectContextApi';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useMobileAppActions } from '@/apps/mobileAppContext';
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
@@ -61,9 +62,15 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
// here: `loadSkills` already gates its own fetch, and wrapping it again
// would hold a second slot idle for the length of the first.
const loadSkills = useSkillsStore((state) => state.loadSkills);
// `isConnected` is a dependency, not a gate: skills are discovered on the
// connected instance and their caches are dropped when instances switch, so
// the count has to be asked for again once the new instance is up. Two
// instances can hold the same project path, which leaves `directory`
// unchanged across a switch.
const isConnected = useConfigStore((state) => state.isConnected);
React.useEffect(() => {
void loadSkills();
}, [directory, loadSkills]);
}, [directory, isConnected, loadSkills]);
/**
* What this session carries. Read from the server
@@ -2,6 +2,7 @@ import React from 'react';
import { useI18n } from '@/lib/i18n';
import { Switch } from '@/components/ui/switch';
import { useMcpStore } from '@/stores/useMcpStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { McpIcon } from '@/components/icons/McpIcon';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { toast } from 'sonner';
@@ -28,6 +29,7 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
const ensureMcpFresh = useMcpStore((state) => state.ensureFresh);
const connect = useMcpStore((state) => state.connect);
const disconnect = useMcpStore((state) => state.disconnect);
const isConnected = useConfigStore((state) => state.isConnected);
const [busyServer, setBusyServer] = React.useState<string | null>(null);
// The panel must not depend on the header dropdown having been mounted or
@@ -35,9 +37,12 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
// compete with chat bootstrap traffic for sockets. The section remounts on
// every session switch, so it only asks for a status that is missing or
// older than a minute; connect/disconnect/auth refresh on their own.
// `isConnected` is a dependency, not a gate: MCP status is cached by
// directory alone and dropped on an instance switch, and two instances can
// hold the same project path — so the switch itself has to trigger the ask.
React.useEffect(() => {
void runBackgroundNetworkTask(() => ensureMcpFresh({ directory, silent: true, maxAgeMs: MCP_STATUS_MAX_AGE_MS }));
}, [directory, ensureMcpFresh]);
}, [directory, ensureMcpFresh, isConnected]);
const mcpServers = React.useMemo(
() => Object.entries(mcpStatus ?? {}).sort(([left], [right]) => left.localeCompare(right)),
@@ -43,9 +43,10 @@ export const WorkStatusUsageSection: React.FC = () => {
const groups = useUsageProviderGroups();
const displayMode = useQuotaStore((state) => state.displayMode);
const isLoading = useQuotaStore((state) => state.isLoading);
const quotaResults = useQuotaStore((state) => state.results);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const fetchQuotas = useQuotaStore((state) => state.fetchQuotas);
const ensureQuotasLoadedForRuntime = useQuotaStore((state) => state.ensureLoadedForRuntime);
const isInitialized = useConfigStore((state) => state.isInitialized);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
@@ -54,17 +55,13 @@ export const WorkStatusUsageSection: React.FC = () => {
// `useQuotaAutoRefresh` only schedules an interval — it never performs the
// first fetch. That was owned by the header dropdown's open handler, so the
// panel stayed empty until the user opened it. Kick off the initial load for
// any enabled provider that has not reported yet, background-gated so it
// cannot compete with chat bootstrap traffic.
// panel stayed empty until the user opened it. `ensureLoadedForRuntime` owns
// the once-per-instance load and its readiness rule; asking again is a no-op,
// so this is safe to run on every connection change.
React.useEffect(() => {
if (isLoading || dropdownProviderIds.length === 0) return;
const missingProvider = dropdownProviderIds.some(
(providerId) => !quotaResults.some((result) => result.providerId === providerId),
);
if (!missingProvider) return;
void runBackgroundNetworkTask(() => fetchQuotas(dropdownProviderIds));
}, [dropdownProviderIds, fetchQuotas, isLoading, quotaResults]);
if (!isInitialized) return;
void runBackgroundNetworkTask(() => ensureQuotasLoadedForRuntime());
}, [ensureQuotasLoadedForRuntime, isInitialized]);
React.useEffect(() => {
if (groups.length === 0) return;
@@ -14,6 +14,7 @@ import { toast } from '@/components/ui';
import { isElectronShell, isDesktopShell } from '@/lib/desktop';
import { Icon } from "@/components/icon/Icon";
import { useUIStore } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useI18n } from '@/lib/i18n';
import {
desktopHostProbe,
@@ -37,6 +38,14 @@ import {
resolveCurrentDesktopHost,
runtimeKeyForDesktopHost,
} from '@/lib/desktopCurrentHost';
import {
getDesktopHostStatusSnapshot,
probeDesktopHosts,
setDesktopHostStatus,
pruneDesktopHostStatuses,
subscribeDesktopHostStatuses,
type DesktopHostStatus,
} from '@/lib/desktopHostStatus';
import { scheduleDesktopHostCandidateRefresh } from '@/lib/desktopRelayRestore';
import { adoptRelayTunnel } from '@/lib/relay/runtime-tunnel';
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
@@ -52,17 +61,7 @@ import {
const SSH_CONNECT_TIMEOUT_MS = 90_000;
const SSH_CONNECT_CANCELLED_ERROR = 'SSH connection cancelled';
type HostStatus = {
status: HostProbeResult['status'];
latencyMs: number;
/** Which transport the successful probe used (multi-transport hosts). */
via?: 'relay';
};
// Last known statuses survive the dropdown unmounting (it remounts on every
// open). Rows show the previous result immediately — refreshed quietly by the
// open-probe — instead of shouting "Unknown" at the user for a few seconds.
const lastKnownHostStatuses: Record<string, HostStatus> = {};
type HostStatus = DesktopHostStatus;
type HostDisplayStatus = HostProbeResult['status'] | 'checking' | null;
@@ -247,15 +246,17 @@ export function DesktopHostSwitcherDialog({
const { t } = useI18n();
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const isRuntimeConnected = useConfigStore((state) => state.isConnected);
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>(() => ({ ...lastKnownHostStatuses }));
React.useEffect(() => {
Object.assign(lastKnownHostStatuses, statusById);
}, [statusById]);
// Statuses live outside this component: startup warms them, and the dropdown
// remounts on every open — holding them here is what made each open start
// from nothing and show "Checking" on rows the app already knew about.
const statusSnapshot = React.useSyncExternalStore(subscribeDesktopHostStatuses, getDesktopHostStatusSnapshot, getDesktopHostStatusSnapshot);
const statusById = statusSnapshot.byHostId;
const isProbing = statusSnapshot.isProbing;
const [isLoading, setIsLoading] = React.useState(false);
const [isProbing, setIsProbing] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [switchingHostId, setSwitchingHostId] = React.useState<string | null>(null);
const [sshHostIds, setSshHostIds] = React.useState<Record<string, true>>({});
@@ -347,6 +348,10 @@ export function DesktopHostSwitcherDialog({
nextSshHostIds[instance.id] = true;
}
setConfigHosts(cfg.hosts || []);
// Config is the authoritative host list: drop statuses for instances the
// user removed. Doing this from a probe run instead would clear entries
// every time a run started before the config had finished loading.
pruneDesktopHostStatuses((cfg.hosts || []).map((host) => host.id));
setDefaultHostId(cfg.defaultHostId ?? null);
setSshHostIds(nextSshHostIds);
setSshStatusesById(sshStatusMap);
@@ -362,43 +367,7 @@ export function DesktopHostSwitcherDialog({
}, [t]);
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
if (!isDesktopShell()) return;
setIsProbing(true);
try {
const localClientToken = await getLocalClientToken();
const results = await Promise.all(
hosts.map(async (h) => {
const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || '');
const probeRelayLeg = async (): Promise<HostStatus> => {
const res = await probeRelayDesktopHost(h.relay!, { clientToken, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
return { status: res.status, latencyMs: res.latencyMs, ...(res.status === 'ok' ? { via: 'relay' as const } : {}) };
};
// Relay-only host: no HTTP address — probe through the E2EE tunnel.
if (h.relay && !h.apiUrl) {
return [h.id, await probeRelayLeg()] as const;
}
const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(h) : h.url);
if (!url) {
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
}
const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
// Multi-transport host away from its network: the direct leg fails
// but the relay may still reach it.
if (isBlockedHostStatus(res.status) && h.relay) {
const relayStatus = await probeRelayLeg();
if (relayStatus.status === 'ok') return [h.id, relayStatus] as const;
}
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
})
);
const next: Record<string, HostStatus> = {};
for (const [id, val] of results) {
next[id] = val;
}
setStatusById(next);
} finally {
setIsProbing(false);
}
await probeDesktopHosts(hosts);
}, []);
React.useEffect(() => {
@@ -514,7 +483,7 @@ export function DesktopHostSwitcherDialog({
relayProbeTunnel = 'tunnel' in probe ? probe.tunnel : undefined;
}
}
setStatusById((prev) => ({ ...prev, [host.id]: finalStatus }));
setDesktopHostStatus(host.id, finalStatus);
if (!transport) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
@@ -620,10 +589,7 @@ export function DesktopHostSwitcherDialog({
if (host.id !== LOCAL_HOST_ID && isDesktopShell()) {
setSwitchingHostId(host.id);
const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
}));
setDesktopHostStatus(host.id, { status: probe.status, latencyMs: probe.latencyMs });
if (isBlockedHostStatus(probe.status)) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
@@ -863,12 +829,17 @@ export function DesktopHostSwitcherDialog({
const status = statusById[host.id] || null;
const sshStatus = sshStatusesById[host.id] || null;
// While a probe runs, keep showing the last known result (quiet
// refresh); only fall back to "Checking" when there has never
// been one. "Unknown" is never shown — an unprobed host is by
// definition being checked.
// refresh — the header's refresh icon is the spinner); only fall
// back to "Checking" when there has never been one. "Unknown" is
// never shown — an unprobed host is by definition being checked.
//
// The instance the app is connected to never says "Checking":
// the live connection already answers the question a probe would
// ask, and reporting otherwise reads as the app not knowing where
// it is. A real probe result still wins — it carries the ping.
const statusKind: HostDisplayStatus = isSsh
? sshPhaseToHostStatus(sshStatus?.phase)
: (status?.status ?? 'checking');
: (status?.status ?? (isActive && isRuntimeConnected ? 'ok' : 'checking'));
const isEditing = editingId === host.id;
const effectiveUrl = isLocal ? localOrigin : (normalizeHostUrl(host.url) || host.url);
const displayLabel = host.id === LOCAL_HOST_ID
+2 -27
View File
@@ -124,7 +124,6 @@ const HeaderIconActionButton = React.memo(function HeaderIconActionButton({
type DesktopServicesMenuProps = {
isDesktopApp: boolean;
currentInstanceLabel: string;
compactCurrentInstanceLabel: string;
currentInstanceIsLocal: boolean;
isDesktopServicesOpen: boolean;
setIsDesktopServicesOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -139,7 +138,6 @@ type DesktopServicesMenuProps = {
const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
isDesktopApp,
currentInstanceLabel,
compactCurrentInstanceLabel,
currentInstanceIsLocal,
isDesktopServicesOpen,
setIsDesktopServicesOpen,
@@ -171,12 +169,12 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
: t('header.services.open')}
className={cn(
DESKTOP_HEADER_ICON_BUTTON_CLASS,
isDesktopApp ? 'w-auto max-w-[14rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8'
isDesktopApp ? 'w-auto max-w-[20rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8'
)}
>
<Icon name="server" className="h-[18px] w-[18px]" />
{isDesktopApp ? (
<span className="truncate typography-ui-label font-medium text-foreground">{compactCurrentInstanceLabel}</span>
<span className="truncate typography-ui-label font-medium text-foreground">{currentInstanceLabel}</span>
) : null}
</button>
</DropdownMenuTrigger>
@@ -251,27 +249,6 @@ const isSameContextUsage = (
&& (a.lastMessageId ?? '') === (b.lastMessageId ?? '');
};
const formatCompactHeaderLabel = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
const words = trimmed.split(/\s+/).filter(Boolean);
if (words.length >= 2) {
const first = words[0];
const second = words[1].slice(0, 3);
const shortTwoWord = `${first} ${second}`.trim();
if (words.length > 2 || shortTwoWord.length < trimmed.length) {
return `${shortTwoWord}...`;
}
return shortTwoWord;
}
return trimmed.length > 12 ? `${trimmed.slice(0, 9).trimEnd()}...` : trimmed;
};
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
@@ -447,7 +424,6 @@ export const Header: React.FC = () => {
const [remoteUpdateInfo, setRemoteUpdateInfo] = React.useState<UpdateInfo | null>(null);
const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false);
const [remoteUpdateError, setRemoteUpdateError] = React.useState<string | null>(null);
const compactCurrentInstanceLabel = React.useMemo(() => formatCompactHeaderLabel(currentInstanceLabel), [currentInstanceLabel]);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
// While the work-status panel is on screen it already reports the project,
// the branch and the context fill — three paces away in the same window.
@@ -1293,7 +1269,6 @@ export const Header: React.FC = () => {
<DesktopServicesMenu
isDesktopApp={isDesktopApp}
currentInstanceLabel={currentInstanceLabel}
compactCurrentInstanceLabel={compactCurrentInstanceLabel}
currentInstanceIsLocal={currentInstanceIsLocal}
isDesktopServicesOpen={isDesktopServicesOpen}
setIsDesktopServicesOpen={setIsDesktopServicesOpen}
@@ -5,21 +5,23 @@ import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
type ProviderId = 'ollama-cloud' | 'cursor';
type ProviderId = 'exe-dev' | 'ollama-cloud' | 'cursor';
type Status = { configured: boolean; secretMasked?: string };
type CredentialPayload = { usageToken?: string; cookie?: string; accessToken?: string; refreshToken?: string };
const EXE_DEV_TOKEN_COMMAND = `ssh exe.dev "ssh-key generate-api-key --label=openchamber --exp=30d --cmds='billing credits usage'"`;
export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: string }> = ({ providerId, providerName }) => {
const { t } = useI18n();
const [status, setStatus] = React.useState<Status | null>(null);
const [values, setValues] = React.useState<Record<string, string>>({});
const [values, setValues] = React.useState<CredentialPayload>({});
const [busy, setBusy] = React.useState(false);
const route = `/api/quota/credentials/${providerId}`;
React.useEffect(() => { void runtimeFetch(route).then(async (response) => {
if (!response.ok) throw new Error();
const next = await response.json() as Status;
const next: Status = await response.json();
setStatus(next); setValues({});
}).catch(() => setStatus({ configured: false })); }, [route]);
const request = async (path: string, method: string, body?: object) => {
const request = async (path: string, method: string, body?: CredentialPayload) => {
setBusy(true);
try {
const response = await runtimeFetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
@@ -31,11 +33,16 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName:
} catch (error) { toast.error(error instanceof Error && error.message ? error.message : t('settings.providers.page.openCodeGo.saveFailed')); }
finally { setBusy(false); }
};
const field = (name: string, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type="password" autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
const field = (name: keyof CredentialPayload, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type="password" autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
return <div data-settings-item={`usage.${providerId}-credentials`} className="mb-8">
<div className="mb-1 px-1"><h3 className="typography-ui-header font-medium text-foreground">{providerName}</h3></div>
<section className="space-y-3 px-2 pb-2 pt-0">
{providerId === 'exe-dev' && <div className="space-y-1.5">
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.quotaCredentials.exeDevTokenInstructions')}</p>
<code className="typography-code block whitespace-pre-wrap break-all rounded bg-muted/50 px-2 py-1.5 text-xs text-foreground">{EXE_DEV_TOKEN_COMMAND}</code>
</div>}
{providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'session=...')}
{providerId === 'exe-dev' && field('usageToken', t('settings.providers.page.quotaCredentials.usageToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
{providerId === 'cursor' && field('accessToken', t('settings.providers.page.quotaCredentials.accessToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
{providerId === 'cursor' && field('refreshToken', t('settings.providers.page.quotaCredentials.refreshToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
<div className="flex flex-wrap gap-2">
@@ -80,7 +80,7 @@ export const UsagePage: React.FC = () => {
? selectedResult.error
: null;
const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false;
const hasCredentialsForm = selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
const hasCredentialsForm = selectedProviderId === 'exe-dev' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
const handleDropdownToggle = React.useCallback((enabled: boolean) => {
if (!selectedProviderId) {
return;
@@ -204,7 +204,7 @@ export const UsagePage: React.FC = () => {
</div>
)}
{(selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
{(selectedProviderId === 'exe-dev' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
<QuotaCredentials providerId={selectedProviderId} providerName={providerName} />
)}
@@ -285,7 +285,14 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
className={cn(
'flex min-w-0 flex-1 items-center gap-1.5 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-[padding]',
// Reserve hover space for the absolute action buttons,
// matching the collapse-toggle branch below.
isRepo && !hideDirectoryControls
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
)}
aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })}
>
<ProjectHeaderIdentity id={id} projectLabel={projectLabel} projectIcon={projectIcon} projectColor={projectColor} projectIconImage={projectIconImage} projectIconBackground={projectIconBackground} />
@@ -2,6 +2,8 @@ import React from 'react';
import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web';
import { cn } from '@/lib/utils';
import { loadMonoFont } from '@/lib/fontLoader';
import type { MonoFontOption } from '@/lib/fontOptions';
import type { TerminalTheme } from '@/lib/terminalTheme';
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
import {
@@ -27,20 +29,24 @@ const loadGhostty = (): Promise<GhosttyRuntime> =>
ghostty: await module.Ghostty.load(),
}));
// The web entry defers its ~2 MB Nerd Font download until a terminal actually
// mounts (see the `__openchamberEnsureNerdFonts` hook in index.html). Wait for
// it with a short bound so a cached font is in place before the glyph atlas is
// built, while a cold CDN fetch never blocks the terminal from opening; the
// runtimes without the hook (VS Code, mobile) resolve immediately.
const NERD_FONT_WAIT_MS = 2000;
const ensureNerdFonts = (): Promise<void> => {
if (typeof window === 'undefined') return Promise.resolve();
const loader = (window as typeof window & { __openchamberEnsureNerdFonts?: () => Promise<void> }).__openchamberEnsureNerdFonts;
if (typeof loader !== 'function') return Promise.resolve();
return Promise.race([
Promise.resolve(loader()).catch(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, NERD_FONT_WAIT_MS)),
]).then(() => undefined);
// Wait briefly for both the selected mono font and the web entry's deferred
// Nerd Fonts before Ghostty measures glyphs. A cold CDN fetch must not block
// opening the terminal, so the renderer starts after the bound and is rebuilt
// once the fonts arrive. Runtimes without the Nerd Font hook resolve it at once.
const TERMINAL_FONT_WAIT_MS = 2000;
const loadNerdFonts = (): Promise<void> =>
Promise.resolve(window.__openchamberEnsureNerdFonts?.()).catch(() => undefined);
const waitForTerminalFonts = (font: MonoFontOption) => {
const loaded = Promise.all([loadMonoFont(font), loadNerdFonts()]).then(() => undefined);
const loadedBeforeTimeout = new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => resolve(false), TERMINAL_FONT_WAIT_MS);
void loaded.then(() => {
clearTimeout(timeout);
resolve(true);
});
});
return { loaded, loadedBeforeTimeout };
};
type TerminalSize = { cols: number; rows: number };
@@ -91,6 +97,7 @@ type Props = {
onInput: (data: string) => void;
onResize: (cols: number, rows: number) => void;
theme: TerminalTheme;
monoFont: MonoFontOption;
fontFamily: string;
fontSize: number;
className?: string;
@@ -100,7 +107,7 @@ type Props = {
};
const TerminalViewport = React.forwardRef<TerminalController, Props>(({
sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className,
sessionKey, chunks, onInput, onResize, theme, monoFont, fontFamily, fontSize, className,
enableTouchScroll = false, autoFocus = true, isVisible = true,
}, ref) => {
const containerRef = React.useRef<HTMLDivElement>(null);
@@ -236,7 +243,8 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
window.addEventListener('focus', handleWindowFocus);
window.addEventListener('blur', handleWindowBlur);
Promise.all([loadGhostty(), ensureNerdFonts()]).then(([{ module, ghostty }]) => {
const fonts = waitForTerminalFonts(monoFont);
Promise.all([loadGhostty(), fonts.loadedBeforeTimeout]).then(([{ module, ghostty }, fontsLoaded]) => {
if (disposed) return;
terminal = new module.Terminal({
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
@@ -264,6 +272,13 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
const safeReset = safeResetRef.current;
if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`);
fitFrame = requestAnimationFrame(fit);
if (!fontsLoaded) {
void fonts.loaded.then(() => {
if (!disposed && terminalRef.current === terminal) {
setRendererGeneration((value) => value + 1);
}
});
}
});
return () => {
@@ -301,7 +316,7 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
writeEpochRef.current += 1;
rendererReadyRef.current = false;
};
}, [fit, fontFamily, fontSize, rendererGeneration, theme]);
}, [fit, fontFamily, fontSize, monoFont, rendererGeneration, theme]);
React.useEffect(() => {
const terminal = terminalRef.current;
+7 -2
View File
@@ -256,6 +256,7 @@ type ContentProps = React.ComponentProps<typeof BaseTooltip.Popup> & {
sideOffset?: number;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
showArrow?: boolean;
};
function TooltipContent({
@@ -265,6 +266,7 @@ function TooltipContent({
align,
children,
style,
showArrow = false,
...props
}: ContentProps) {
return (
@@ -277,14 +279,17 @@ function TooltipContent({
// data-instant is set when moving between grouped tooltips
// (shared TooltipProvider): reposition without replaying the
// full exit/enter animation.
"oc-glass-tooltip text-[var(--surface-elevated-foreground)] border border-border/60 transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 data-[instant]:transition-none data-[instant]:duration-0 z-50 w-fit origin-[var(--transform-origin)] rounded-xl px-3 py-1.5 typography-meta text-balance overflow-hidden",
"oc-glass-tooltip text-[var(--surface-elevated-foreground)] border border-border/60 transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 data-[instant]:transition-none data-[instant]:duration-0 z-50 w-fit origin-[var(--transform-origin)] rounded-xl px-3 py-1.5 typography-meta text-balance",
showArrow ? "overflow-visible" : "overflow-hidden",
className
)}
style={{ ...style }}
{...props}
>
{children}
<BaseTooltip.Arrow className="fill-[var(--surface-elevated)] z-50 size-2" />
{showArrow ? (
<BaseTooltip.Arrow className="relative z-50 block h-1.5 w-3 overflow-clip data-[side=bottom]:top-[-6px] data-[side=left]:right-[-9px] data-[side=left]:rotate-90 data-[side=right]:left-[-9px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-6px] data-[side=top]:rotate-180 before:absolute before:bottom-0 before:left-1/2 before:block before:h-[calc(6px*sqrt(2))] before:w-[calc(6px*sqrt(2))] before:border before:border-border/60 before:bg-[var(--surface-elevated)] before:content-[''] before:[transform:translate(-50%,50%)_rotate(45deg)]" />
) : null}
</BaseTooltip.Popup>
</BaseTooltip.Positioner>
</BaseTooltip.Portal>
+97 -1
View File
@@ -58,6 +58,7 @@ import { GitEmptyState } from './git/GitEmptyState';
import { HistorySection } from './git/HistorySection';
import { ConflictDialog } from './git/ConflictDialog';
import { StashDialog } from './git/StashDialog';
import { DirtyBranchSwitchDialog } from './git/DirtyBranchSwitchDialog';
import { InProgressOperationBanner } from './git/InProgressOperationBanner';
import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection';
import { deriveBaseBranch } from './git/baseBranch';
@@ -706,6 +707,8 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}
}, [conflictStorageKey, gitDirectory]);
const [stashDialogOpen, setStashDialogOpen] = React.useState(false);
// Branch a dirty-tree switch is waiting on; null when no switch is blocked.
const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState<string | null>(null);
const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge');
const [stashDialogBranch, setStashDialogBranch] = React.useState('');
@@ -1371,6 +1374,21 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
return;
}
// A checkout over uncommitted changes can carry them onto the target
// branch, conflict, or silently rewrite what the user was editing. The
// switch is blocked until the working tree is resolved: commit, or
// explicitly revert (DirtyBranchSwitchDialog).
if ((status?.files?.length ?? 0) > 0) {
setPendingDirtySwitchBranch(normalized);
return;
}
await performCheckout(normalized);
};
const performCheckout = async (branch: string) => {
if (!gitDirectory) return;
const normalized = branch;
try {
// Picking a remote-tracking branch checks out the local branch that
// tracks it, so report the branch the repository actually landed on.
@@ -2369,7 +2387,8 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
return (
<div className={cn('flex h-full flex-col overflow-hidden')}>
<GitHeader
<GitHeader
directory={gitDirectory ?? ''}
status={status}
localBranches={localBranches}
remoteBranches={remoteBranches}
@@ -2670,6 +2689,83 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
/>
)}
<DirtyBranchSwitchDialog
open={pendingDirtySwitchBranch !== null}
onOpenChange={(open) => { if (!open) setPendingDirtySwitchBranch(null); }}
targetBranch={pendingDirtySwitchBranch ?? ''}
changedFileCount={status?.files?.length ?? 0}
onCommitAndSwitch={async (message, pushAfter) => {
const branch = pendingDirtySwitchBranch;
if (!branch || !gitDirectory) return;
const sourceBranch = status?.current ?? null;
await git.createGitCommit(gitDirectory, message, { addAll: true });
bumpIndexRevision(gitDirectory);
let pushedRemoteName: string | null = null;
if (pushAfter) {
const trackingRemoteName = status?.tracking?.split('/')[0];
const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0];
try {
if (!remote) throw new Error(t('mobile.changes.noRemote'));
await git.gitPush(gitDirectory, status?.tracking
? { remote: remote.name }
: { remote: remote.name, branch: sourceBranch ?? undefined, options: ['--set-upstream'] });
pushedRemoteName = remote.name;
} catch (error) {
// The commit stands, so nothing is lost — but the switch is
// cancelled: the user must see the failed push on the branch it
// belongs to instead of discovering it later from elsewhere.
console.error('Push after commit failed:', error);
toast.error(t('gitView.dirtySwitch.pushFailed'));
await refreshStatusAndBranches();
await refreshLog();
setPendingDirtySwitchBranch(null);
return;
}
}
// Without a push the commit stays local on the branch being left;
// after the switch nothing on screen would say so, so the toast must.
toast.success(pushedRemoteName
? t('gitView.toast.pushedToUpstream', { name: pushedRemoteName })
: sourceBranch
? t('gitView.dirtySwitch.committedNotPushed', { branch: sourceBranch })
: t('gitView.toast.commitCreated'));
await refreshStatusAndBranches();
await refreshLog();
setPendingDirtySwitchBranch(null);
await performCheckout(branch);
}}
onGenerateMessage={async () => {
if (!gitDirectory) return '';
const paths = (status?.files ?? []).map((file) => file.path).sort();
const { message } = await generateSessionCommitMessage(gitDirectory, paths);
const subject = message.subject?.trim() ?? '';
// Same gitmoji decoration as the commit panel's Generate button.
if (subject && settingsGitmojiEnabled && gitmojiEmojis.length > 0) {
const match = matchGitmojiFromSubject(subject, gitmojiEmojis);
if (match && !subject.startsWith(match.code) && !subject.startsWith(match.emoji)) {
return `${match.code} ${subject}`;
}
}
return subject;
}}
onRevertAndSwitch={async () => {
const branch = pendingDirtySwitchBranch;
if (!branch || !gitDirectory) return;
const paths = (status?.files ?? []).map((file) => file.path);
await handleRevertPaths(paths, true, 'all');
// The revert reports its own partial failures; the checkout happens
// only once the tree is verifiably clean, so a half-reverted tree is
// never switched over.
const fresh = await git.getGitStatus(gitDirectory);
if (!fresh.isClean && (fresh.files?.length ?? 0) > 0) {
toast.error(t('gitView.dirtySwitch.revertIncomplete'));
return;
}
setPendingDirtySwitchBranch(null);
await performCheckout(branch);
}}
/>
<StashDialog
open={stashDialogOpen}
onOpenChange={setStashDialogOpen}
@@ -251,6 +251,14 @@ export const LinearIssuesView: React.FC = () => {
const setListPriority = useUIStore((state) => state.setLinearIssueListPriority);
const resetListFilters = useUIStore((state) => state.resetLinearIssueListFilters);
const setLinearIssueFocus = useUIStore((state) => state.setLinearIssueFocus);
const applyLinearFiltersForRuntime = useUIStore((state) => state.applyLinearIssueListFiltersForRuntime);
// The team filter is stored per instance, and rehydration can run before the
// runtime endpoint is known. Reading it here means the view always opens on
// the filter belonging to the instance it is about to query.
React.useEffect(() => {
applyLinearFiltersForRuntime();
}, [applyLinearFiltersForRuntime]);
const [query, setQuery] = React.useState('');
const [searchOpen, setSearchOpen] = React.useState(false);
@@ -1140,6 +1140,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
onInput={handleViewportInput}
onResize={handleViewportResize}
theme={xtermTheme}
monoFont={monoFont}
fontFamily={resolvedFontStack}
fontSize={terminalFontSize}
enableTouchScroll={useTouchTerminalInput}
@@ -100,4 +100,13 @@ describe('terminal viewport remount guard', () => {
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
});
test('rebuilds the canvas renderer when terminal fonts finish loading after the startup bound', () => {
expect(terminalViewportSource).toContain('loadMonoFont(font)');
expect(terminalViewportSource).toContain('Promise.all([loadMonoFont(font), loadNerdFonts()])');
expect(terminalViewportSource).toContain('Promise.all([loadGhostty(), fonts.loadedBeforeTimeout])');
expect(terminalViewportSource).toContain('if (!fontsLoaded)');
expect(terminalViewportSource).toContain('void fonts.loaded.then(() => {');
expect(terminalViewportSource).toContain('setRendererGeneration((value) => value + 1)');
});
});
@@ -16,9 +16,13 @@ import {
} from '@/components/ui/command';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import type { GitRemote } from '@/lib/api/types';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { useI18n } from '@/lib/i18n';
import { useDeviceInfo } from '@/lib/device';
import { getGitUnpushedBranchCounts } from '@/lib/gitApi';
import { getRecentBranches, rememberRecentBranch } from './recentBranches';
interface BranchInfo {
ahead?: number;
@@ -30,10 +34,18 @@ interface BranchSelectorProps {
localBranches: string[];
remoteBranches: string[];
branchInfo: Record<string, BranchInfo> | undefined;
currentBranchAhead?: number;
onCheckout: (branch: string) => void;
onCreate: (name: string, remote?: GitRemote) => Promise<void>;
remotes?: GitRemote[];
disabled?: boolean;
directory: string;
/**
* Shown above the branch list while the working tree has uncommitted
* changes: selecting a branch will not switch directly but opens the
* commit-or-revert resolution instead.
*/
switchBlockedNotice?: string | null;
}
const sanitizeBranchNameInput = (value: string): string => {
@@ -54,18 +66,24 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
localBranches,
remoteBranches,
branchInfo,
currentBranchAhead = 0,
onCheckout,
onCreate,
remotes = [],
disabled = false,
directory,
switchBlockedNotice = null,
}) => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
const [isOpen, setIsOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const [showCreate, setShowCreate] = React.useState(false);
const [showRemoteSelect, setShowRemoteSelect] = React.useState(false);
const [newBranchName, setNewBranchName] = React.useState('');
const [isCreating, setIsCreating] = React.useState(false);
const [recentBranches, setRecentBranches] = React.useState<string[]>(() => getRecentBranches(directory));
const [unpushedCounts, setUnpushedCounts] = React.useState<Record<string, number>>({});
const createInputRef = React.useRef<HTMLInputElement>(null);
const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
@@ -94,6 +112,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
setIsOpen(false);
return;
}
setRecentBranches(rememberRecentBranch(directory, branch));
onCheckout(branch);
setIsOpen(false);
setSearch('');
@@ -158,6 +177,109 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
}
}, [isOpen]);
React.useEffect(() => {
if (!directory) return;
setRecentBranches(currentBranch
? rememberRecentBranch(directory, currentBranch)
: getRecentBranches(directory));
}, [currentBranch, directory]);
React.useEffect(() => {
if (!isOpen) return;
const branches = recentBranches.filter((branch) => localBranches.includes(branch)).slice(0, 5);
if (branches.length === 0) return setUnpushedCounts({});
let cancelled = false;
getGitUnpushedBranchCounts(directory, branches)
.then(({ counts }) => { if (!cancelled) setUnpushedCounts(counts); })
.catch(() => { if (!cancelled) setUnpushedCounts({}); });
return () => { cancelled = true; };
}, [directory, isOpen, localBranches, recentBranches]);
if (isMobile) {
const recentLocalBranches = recentBranches.filter((branch) => localBranches.includes(branch));
const renderBranch = (branch: string, remote = false) => {
const ahead = unpushedCounts[branch] ?? (branch === currentBranch ? currentBranchAhead : 0);
const aheadLabel = ahead === 1
? t('gitView.branch.unpushedSingle')
: t('gitView.branch.unpushedPlural', { count: ahead });
return (
<button
key={`${remote ? 'remote' : 'local'}-${branch}`}
type="button"
onClick={() => handleCheckout(branch)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-2.5 text-left typography-ui-label hover:bg-interactive-hover"
>
<span className="min-w-0 flex-1 truncate">{branch}</span>
{ahead > 0 ? (
<span
className="inline-flex shrink-0 items-center gap-1 typography-micro text-muted-foreground"
title={aheadLabel}
aria-label={aheadLabel}
>
<Icon name="arrow-up" className="size-3" aria-hidden="true" />
<span aria-hidden="true">{ahead}</span>
</span>
) : null}
{currentBranch === branch ? <Icon name="check" className="size-4 shrink-0 text-primary" /> : null}
</button>
);
};
return (
<>
<Button
variant="ghost"
size="sm"
className="h-8 min-w-0 max-w-full justify-start gap-1.5 px-2 py-1"
disabled={disabled}
onClick={() => setIsOpen(true)}
>
<Icon name="git-branch" className="size-4 text-primary" />
<span className="min-w-0 truncate font-medium text-left">
{currentBranch || t('gitView.branch.detachedHead')}
</span>
<Icon name="arrow-down-s" className="size-4 opacity-60" />
</Button>
<MobileOverlayPanel
open={isOpen}
title={t('gitView.branch.currentBranchTooltip')}
onClose={() => setIsOpen(false)}
>
<div className="flex flex-col gap-2 px-3 pb-4 pt-1">
<input
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('gitView.branch.searchPlaceholder')}
className="h-9 w-full rounded-lg border border-border bg-transparent px-3 typography-meta outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary"
/>
{switchBlockedNotice ? (
<div className="flex items-start gap-2 px-2 py-1">
<Icon name="alert" className="mt-0.5 size-3.5 shrink-0 text-[var(--status-warning)]" aria-hidden="true" />
<span className="typography-micro text-muted-foreground">{switchBlockedNotice}</span>
</div>
) : null}
{recentLocalBranches.length > 0 ? (
<section>
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.recentBranches')}</p>
{recentLocalBranches.map((branch) => renderBranch(branch))}
</section>
) : null}
<section>
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.localBranches')}</p>
{filteredLocal.map((branch) => renderBranch(branch))}
</section>
<section>
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.remoteBranches')}</p>
{filteredRemote.map((branch) => renderBranch(branch, true))}
</section>
</div>
</MobileOverlayPanel>
</>
);
}
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
@@ -192,6 +314,12 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
onValueChange={setSearch}
onKeyDown={stopDropdownTypeahead}
/>
{switchBlockedNotice ? (
<div className="flex items-start gap-2 border-b border-border/60 px-3 py-2">
<Icon name="alert" className="mt-0.5 size-3.5 shrink-0 text-[var(--status-warning)]" aria-hidden="true" />
<span className="typography-micro text-muted-foreground">{switchBlockedNotice}</span>
</div>
) : null}
<CommandList
scrollbarClassName="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
disableHorizontal
@@ -288,6 +416,38 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
<CommandSeparator />
{recentBranches.filter((branch) => localBranches.includes(branch)).length > 0 ? (
<>
<CommandGroup heading={t('gitView.branch.recentBranches')}>
{recentBranches.filter((branch) => localBranches.includes(branch)).map((branch) => (
<CommandItem key={`recent-${branch}`} onSelect={() => handleCheckout(branch)}>
<span className="flex flex-1 items-center gap-2 min-w-0">
<span className="typography-ui-label text-foreground truncate">{branch}</span>
{(() => {
const ahead = unpushedCounts[branch] ?? (branch === currentBranch ? currentBranchAhead : 0);
const aheadLabel = ahead === 1
? t('gitView.branch.unpushedSingle')
: t('gitView.branch.unpushedPlural', { count: ahead });
return ahead > 0 ? (
<span
className="inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 typography-micro text-muted-foreground"
title={aheadLabel}
aria-label={aheadLabel}
>
<Icon name="arrow-up" className="size-3" aria-hidden="true" />
<span aria-hidden="true">{ahead}</span>
</span>
) : null;
})()}
</span>
{currentBranch === branch ? <span className="typography-micro text-primary">{t('gitView.branch.currentBadge')}</span> : null}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
) : null}
<CommandGroup heading={t('gitView.branch.localBranches')}>
{filteredLocal.map((branch) => (
<CommandItem
@@ -0,0 +1,197 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
interface DirtyBranchSwitchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
targetBranch: string;
changedFileCount: number;
/**
* Commit every uncommitted change with this message pushing the commit
* first when the user opted in then perform the checkout.
*/
onCommitAndSwitch: (message: string, pushAfter: boolean) => Promise<void>;
/** Produce an AI commit message for the current changes, same as the commit panel. */
onGenerateMessage: () => Promise<string>;
/** Revert every uncommitted change, then perform the checkout. */
onRevertAndSwitch: () => Promise<void>;
}
/**
* Switching branches with uncommitted changes is blocked so a checkout can
* never silently carry, conflict with, or drop the user's work. The user
* resolves the working tree with one explicit choice: commit and switch
* (message written, generated on demand, or generated automatically when the
* field is left empty same pipeline as the commit panel), or revert and
* switch. Cancel leaves everything untouched for a fully manual flow.
*/
export const DirtyBranchSwitchDialog: React.FC<DirtyBranchSwitchDialogProps> = ({
open,
onOpenChange,
targetBranch,
changedFileCount,
onCommitAndSwitch,
onGenerateMessage,
onRevertAndSwitch,
}) => {
const { t } = useI18n();
const [commitMessage, setCommitMessage] = React.useState('');
const [pendingAction, setPendingAction] = React.useState<'generate' | 'commit' | 'revert' | null>(null);
const [pushAfter, setPushAfter] = React.useState(false);
const isProcessing = pendingAction !== null;
React.useEffect(() => {
if (!open) {
setCommitMessage('');
setPushAfter(false);
}
}, [open]);
const handleGenerate = async () => {
setPendingAction('generate');
try {
const generated = await onGenerateMessage();
if (generated) setCommitMessage(generated);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed'));
} finally {
setPendingAction(null);
}
};
// An empty field is not an obstacle: the message is generated on the spot,
// through the same pipeline as the commit panel, and the commit proceeds.
const handleCommitAndSwitch = async () => {
setPendingAction('commit');
try {
let message = commitMessage.trim();
if (!message) {
message = (await onGenerateMessage()).trim();
if (!message) {
toast.error(t('gitView.toast.enterCommitMessage'));
return;
}
setCommitMessage(message);
}
await onCommitAndSwitch(message, pushAfter);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed'));
} finally {
setPendingAction(null);
}
};
const handleRevertAndSwitch = async () => {
setPendingAction('revert');
try {
await onRevertAndSwitch();
} catch (err) {
toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed'));
} finally {
setPendingAction(null);
}
};
return (
<Dialog open={open} onOpenChange={(next) => { if (!isProcessing) onOpenChange(next); }}>
<DialogContent className="max-w-md w-[calc(100vw-2rem)]">
<div className="flex flex-col gap-4">
<DialogHeader>
<div className="flex items-center gap-2">
<Icon name="alert" className="size-5 shrink-0 text-[var(--status-warning)]" />
<DialogTitle>{t('gitView.dirtySwitch.title')}</DialogTitle>
</div>
<DialogDescription>
{changedFileCount === 1
? t('gitView.dirtySwitch.descriptionSingle', { branch: targetBranch })
: t('gitView.dirtySwitch.descriptionPlural', { branch: targetBranch, count: changedFileCount })}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 rounded-lg border border-border/60 px-3 py-2 focus-within:border-border">
<input
value={commitMessage}
onChange={(event) => setCommitMessage(event.target.value)}
placeholder={t('gitView.commit.messagePlaceholder')}
disabled={isProcessing}
onKeyDown={(event) => {
if (event.key === 'Enter' && !isProcessing) {
event.preventDefault();
void handleCommitAndSwitch();
}
}}
className="min-w-0 flex-1 bg-transparent typography-meta text-foreground outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
onClick={() => { void handleGenerate(); }}
disabled={isProcessing}
aria-label={t('gitView.commit.generate')}
title={t('gitView.commit.generate')}
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-50"
>
{pendingAction === 'generate' ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name="ai-generate-2" className="size-4 text-primary" />
)}
</button>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={pushAfter}
onChange={setPushAfter}
disabled={isProcessing}
ariaLabel={t('gitView.dirtySwitch.pushAfterCommit')}
/>
<span
className="typography-ui-label text-foreground cursor-pointer select-none"
onClick={() => !isProcessing && setPushAfter(!pushAfter)}
>
{t('gitView.dirtySwitch.pushAfterCommit')}
</span>
</div>
<div className="flex items-center justify-between gap-2 pt-1">
<Button
variant="destructive"
size="sm"
onClick={() => { void handleRevertAndSwitch(); }}
disabled={isProcessing}
className="gap-2"
>
{pendingAction === 'revert' ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : null}
{t('gitView.dirtySwitch.revertAndSwitch')}
</Button>
<Button
variant="default"
size="sm"
onClick={() => { void handleCommitAndSwitch(); }}
disabled={isProcessing}
className="gap-2"
>
{pendingAction === 'commit' ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : null}
{t('gitView.dirtySwitch.commitAndSwitch')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -22,10 +22,12 @@ import type {
GitHubChecksSummary,
} from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { useDeviceInfo } from '@/lib/device';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
interface GitHeaderProps {
directory: string;
status: GitStatus | null;
localBranches: string[];
remoteBranches: string[];
@@ -240,6 +242,7 @@ const UpstreamStatusPill: React.FC<UpstreamStatusPillProps> = ({
};
export const GitHeader: React.FC<GitHeaderProps> = ({
directory,
status,
localBranches,
remoteBranches,
@@ -272,6 +275,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
repositoryRoot,
}) => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
if (!status) {
return null;
}
@@ -425,20 +429,23 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
<header className="@container/git-header px-3 py-2 bg-transparent">
<div className="flex items-center justify-between gap-2 min-w-0">
<div className="flex min-w-0 flex-1 items-center gap-1">
{isWorktreeMode ? (
{isWorktreeMode && !isMobile ? (
<WorktreeBranchDisplay
currentBranch={status.current}
onRename={onRenameBranch}
/>
) : (
<BranchSelector
directory={directory}
currentBranch={status.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branchInfo}
currentBranchAhead={status.ahead}
onCheckout={onCheckoutBranch}
onCreate={onCreateBranch}
remotes={remotes}
switchBlockedNotice={(status.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null}
/>
)}
{repositoryOptionsForPicker.length > 0 && onSelectRepository ? (
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { getRecentBranches, rememberRecentBranch } from './recentBranches';
class TestStorage implements Storage {
#values = new Map<string, string>();
get length(): number { return this.#values.size; }
clear(): void { this.#values.clear(); }
getItem(key: string): string | null { return this.#values.get(key) ?? null; }
key(index: number): string | null { return [...this.#values.keys()][index] ?? null; }
removeItem(key: string): void { this.#values.delete(key); }
setItem(key: string, value: string): void { this.#values.set(key, value); }
}
const originalLocalStorage = globalThis.localStorage;
let storage: TestStorage;
beforeEach(() => {
storage = new TestStorage();
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage });
});
afterEach(() => {
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: originalLocalStorage });
});
describe('recent Git branches', () => {
test('persists a branch list for a later UI mount', () => {
rememberRecentBranch('/repo', 'feature/one');
rememberRecentBranch('/repo', 'feature/two');
expect(getRecentBranches('/repo')).toEqual(['feature/two', 'feature/one']);
});
test('keeps only the five most recently used branches', () => {
for (let index = 1; index <= 6; index += 1) {
rememberRecentBranch('/repo', `feature/${index}`);
}
expect(getRecentBranches('/repo')).toEqual([
'feature/6', 'feature/5', 'feature/4', 'feature/3', 'feature/2',
]);
});
});
@@ -0,0 +1,38 @@
import { normalizePath } from '@/lib/pathNormalization';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { z } from 'zod';
const KEY = 'openchamber:recent-git-branches:v1';
const LIMIT = 5;
const entriesSchema = z.record(z.string(), z.array(z.string()));
type Entries = z.infer<typeof entriesSchema>;
const keyFor = (directory: string): string | null => {
const normalized = normalizePath(directory);
return normalized ? `${getRuntimeKey()}:${normalized}` : null;
};
const read = (): Entries => {
try {
const raw = localStorage.getItem(KEY);
const parsed = entriesSchema.safeParse(raw ? JSON.parse(raw) : null);
return parsed.success
? Object.fromEntries(Object.entries(parsed.data).map(([key, branches]) => [key, branches.slice(0, LIMIT)]))
: {};
} catch { return {}; }
};
export const getRecentBranches = (directory: string): string[] => {
const key = keyFor(directory);
return key ? read()[key] ?? [] : [];
};
export const rememberRecentBranch = (directory: string, branch: string): string[] => {
const key = keyFor(directory);
if (!key || !branch) return [];
const entries = read();
const next = [branch, ...(entries[key] ?? []).filter((item) => item !== branch)].slice(0, LIMIT);
try { localStorage.setItem(KEY, JSON.stringify({ ...entries, [key]: next })); } catch { /* convenience only */ }
return next;
};