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