Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 25 additions & 17 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
import { useTelemetry } from "./hooks/useTelemetry";
import { getRuntimeTypeForTelemetry } from "@/common/telemetry";
import { useStartWorkspaceCreation } from "./hooks/useStartWorkspaceCreation";
import { useAPI } from "@/browser/contexts/API";
import { useAPI, useLiveAPI } from "@/browser/contexts/API";
import { requestActiveTurnThinkingLevel } from "@/browser/utils/activeTurnThinking";
import { getAutoRouting, getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync";
import { AuthTokenModal } from "@/browser/components/AuthTokenModal/AuthTokenModal";
Expand Down Expand Up @@ -248,1446 +248,1454 @@
);
}

function AppInner() {
// Get workspace state from context
const {
workspaceMetadata,
loading,
setWorkspaceMetadata,
removeWorkspace,
removeSubagent,
updateWorkspaceTitle,
reorderPinnedWorkspaces,
selectedWorkspace,
setSelectedWorkspace,
pendingNewWorkspaceProject,
pendingNewWorkspaceSubProjectPath,
pendingNewWorkspaceDraftId,
createWorkspaceDraft,
beginWorkspaceCreation,
} = useWorkspaceContext();
const {
currentWorkspaceId,
isAnalyticsOpen,
navigateToAnalytics,
navigateFromAnalytics,
navigateBack,
navigateForward,
usesBrowserHistory,
} = useRouter();
const { themePreference, setTheme, toggleTheme } = useTheme();
const { open: openSettings, isOpen: isSettingsOpen } = useSettings();
const { open: openAboutDialog } = useAboutDialog();
const { confirm: confirmDialog } = useConfirmDialog();
const setThemePreference = useCallback(
(nextTheme: ThemePreference) => {
setTheme(nextTheme);
},
[setTheme]
);
const { layoutPresets, applySlotToWorkspace, saveCurrentWorkspaceToSlot } = useUILayouts();
const { getMinOverride: getMinThinkingOverride } = useMinThinkingLevels();
const { api, status, error, authenticate, retry } = useAPI();
// Handlers and command sources see null offline, even while useAPI() keeps the last client.
const liveApi = useLiveAPI();
// Read after a confirmation dialog: the connection can drop while it is open.
const liveApiRef = useRef(liveApi);
useEffect(() => {
liveApiRef.current = liveApi;
}, [liveApi]);
Comment thread
ThomasK33 marked this conversation as resolved.
const [supportedUpdateChannels, setSupportedUpdateChannels] = useState<UpdateChannel[]>([]);

useEffect(() => {
setSupportedUpdateChannels([]);
if (!api) return;

let active = true;
api.update
.getChannel()
.then(({ supportedChannels }) => {
if (active) setSupportedUpdateChannels(supportedChannels);
})
.catch(console.error);
return () => {
active = false;
};
}, [api]);

const {
userProjects,
refreshProjects,
removeProject,
openProjectCreateModal,
projectCreateInitialPath,
isProjectCreateModalOpen,
closeProjectCreateModal,
addProject,
} = useProjectContext();

// Auto-collapse sidebar on mobile by default
const isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState(
LEFT_SIDEBAR_COLLAPSED_KEY,
isMobile,
{
listener: true,
}
);

const [isMultiProjectWorkspaceModalOpen, setMultiProjectWorkspaceModalOpen] = useState(false);
const multiProjectWorkspacesEnabled = useExperimentValue(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES);
const artifactsEnabled = useExperimentValue(EXPERIMENT_IDS.ARTIFACTS);
const sessionTapesEnabled = useExperimentValue(EXPERIMENT_IDS.SESSION_TAPES);
const autoModelRoutingEnabled = useExperimentValue(EXPERIMENT_IDS.AUTO_MODEL_ROUTING);
const perfFlightRecorderEnabled = useExperimentValue(EXPERIMENT_IDS.PERF_FLIGHT_RECORDER);

// Left sidebar is drag-resizable (mirrors RightSidebar). Width is persisted globally;
// collapse remains a separate toggle and the drag handle is hidden in mobile-touch overlay mode.
const leftSidebar = useResizableSidebar({
enabled: true,
defaultWidth: LEFT_SIDEBAR_DEFAULT_WIDTH_PX,
minWidth: LEFT_SIDEBAR_MIN_WIDTH_PX,
maxWidth: LEFT_SIDEBAR_MAX_WIDTH_PX,
// Keep enough room for the main content so you can't drag-resize the left sidebar
// to a point where the chat pane becomes unusably narrow.
getMaxWidthPx: () => {
// Match LeftSidebar's mobile overlay gate. In that mode we don't want viewport-based clamping
// because the sidebar width is controlled by CSS and shouldn't rewrite the user's desktop
// width preference.
const isMobileTouch =
typeof window !== "undefined" &&
window.matchMedia("(max-width: 768px) and (pointer: coarse)").matches;
if (isMobileTouch) {
return Number.POSITIVE_INFINITY;
}

const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1200;
// ChatPane uses tailwind `min-w-96`.
return viewportWidth - 384;
},
storageKey: LEFT_SIDEBAR_WIDTH_KEY,
side: "left",
});
// Sync sidebar collapse state to the root element for non-React consumers like
// Storybook play helpers that need to know whether the sidebar is currently collapsed.
useEffect(() => {
document.documentElement.dataset.leftSidebarCollapsed = String(sidebarCollapsed);
}, [sidebarCollapsed]);
const creationProjectPath =
!selectedWorkspace && !currentWorkspaceId ? pendingNewWorkspaceProject : null;
// Sub-project creation shares the owning parent's model preference, matching ChatInput.
const creationScope = creationProjectPath
? resolveWorkspaceCreationScope(
creationProjectPath,
userProjects,
pendingNewWorkspaceSubProjectPath
)
: null;
const creationScopeId = creationScope ? getProjectScopeId(creationScope.projectPath) : null;

// History navigation (back/forward)
const location = useLocation();
const locationRef = useRef(location);
locationRef.current = location;

const startWorkspaceCreation = useStartWorkspaceCreation({
projects: userProjects,
beginWorkspaceCreation,
});

// ProjectPage handles its own focus when mounted

const handleToggleSidebar = useCallback(() => {
setSidebarCollapsed((prev) => !prev);
}, [setSidebarCollapsed]);

// Telemetry tracking
const telemetry = useTelemetry();

// Get workspace store for command palette
const workspaceStore = useWorkspaceStoreRaw();

const handleWorkspaceCreated = (
metadata: FrontendWorkspaceMetadata,
options?: WorkspaceCreatedOptions
) => {
workspaceStore.addWorkspace(metadata);
setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata));

if (options?.autoNavigate !== false) {
let createdSelection: WorkspaceSelection | null = null;
setSelectedWorkspace((current) => {
if (current !== null) return current;
createdSelection = toWorkspaceSelection(metadata);
return createdSelection;
});

if (createdSelection) {
if (options?.markPendingInitialSend !== false) {
workspaceStore.markPendingInitialSend(
metadata.id,
options?.pendingStreamModel ?? null,
options?.pendingUserMessage,
options?.pendingCreationInit
);
} else if (options?.pendingCreationInit) {
// Sends without a user turn (initial /goal) still run init: keep the creation card
// on screen until the workspace's own init-start replaces it.
workspaceStore.markPendingCreationInit(metadata.id, options.pendingCreationInit);
}
}
}

telemetry.workspaceCreated(metadata.id, getRuntimeTypeForTelemetry(metadata.runtimeConfig));
};

// Track telemetry when workspace selection changes
const prevWorkspaceRef = useRef<WorkspaceSelection | null>(null);
// Ref for selectedWorkspace to access in callbacks without stale closures
const selectedWorkspaceRef = useRef(selectedWorkspace);
selectedWorkspaceRef.current = selectedWorkspace;
// The settings and analytics modals cover the chat, so a workspace behind them is selected but
// not visible: keep lastRead from advancing and keep its completion notifications.
const isPageCoveredByModal = isSettingsOpen || isAnalyticsOpen;
const isPageCoveredByModalRef = useRef(isPageCoveredByModal);
isPageCoveredByModalRef.current = isPageCoveredByModal;
const visibleChatWorkspaceId = isPageCoveredByModal ? null : currentWorkspaceId;
// Ref for chat visibility to avoid stale closure in response callbacks
const visibleChatWorkspaceIdRef = useRef(visibleChatWorkspaceId);
visibleChatWorkspaceIdRef.current = visibleChatWorkspaceId;
useEffect(() => {
const prev = prevWorkspaceRef.current;
if (prev && selectedWorkspace && prev.workspaceId !== selectedWorkspace.workspaceId) {
telemetry.workspaceSwitched(prev.workspaceId, selectedWorkspace.workspaceId);
}
prevWorkspaceRef.current = selectedWorkspace;
}, [selectedWorkspace, telemetry]);

// Track last-read timestamps for unread indicators.
// Read-marking is gated on chat visibility.
useUnreadTracking(selectedWorkspace, visibleChatWorkspaceId);

const workspaceMetadataRef = useRef(workspaceMetadata);
useEffect(() => {
workspaceMetadataRef.current = workspaceMetadata;
}, [workspaceMetadata]);

// Update window title based on selected workspace
// URL syncing is now handled by RouterContext
useEffect(() => {
if (selectedWorkspace) {
// Update window title with workspace title (or name for legacy workspaces)
const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId);
const workspaceTitle = metadata?.title ?? metadata?.name ?? selectedWorkspace.workspaceId;
const title = `${workspaceTitle} - ${selectedWorkspace.projectName} - ${XUM_PRODUCT_SLUG}`;
// Set document.title locally for browser mode, call backend for Electron
document.title = title;
void api?.window.setTitle({ title });
} else {
// Set document.title locally for browser mode, call backend for Electron
document.title = XUM_PRODUCT_SLUG;
void api?.window.setTitle({ title: XUM_PRODUCT_SLUG });
}
}, [selectedWorkspace, workspaceMetadata, api]);

// Validate selected workspace exists and has all required fields
// Note: workspace validity is now primarily handled by RouterContext deriving
// selectedWorkspace from URL + metadata. This effect handles edge cases like
// stale localStorage or missing fields in legacy workspaces.
useEffect(() => {
if (selectedWorkspace) {
const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId);

if (!metadata) {
// Workspace metadata disappeared while this route was active. Clear the stale
// selection so the router can self-heal to the compatibility root route.
console.warn(
`Workspace ${selectedWorkspace.workspaceId} no longer exists, clearing selection`
);
setSelectedWorkspace(null);
} else if (!selectedWorkspace.namedWorkspacePath && metadata.namedWorkspacePath) {
// Old localStorage entry missing namedWorkspacePath - update it once
console.log(`Updating workspace ${selectedWorkspace.workspaceId} with missing fields`);
setSelectedWorkspace(toWorkspaceSelection(metadata));
}
}
}, [selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);

const openWorkspaceInTerminal = useOpenTerminal();

const handleRemoveProject = useCallback(
async (path: string) => {
if (selectedWorkspace?.projectPath === path) {
setSelectedWorkspace(null);
}
return removeProject(path);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedWorkspace, setSelectedWorkspace]
);

// Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders

// NEW: Get workspace recency from store
const workspaceRecency = useWorkspaceRecency();

// Build sorted workspaces map including pending workspaces
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
const sortedWorkspacesByProject = useStableReference(
() => buildSortedWorkspacesByProject(userProjects, workspaceMetadata, workspaceRecency),
(prev, next) =>
compareMaps(prev, next, (a, b) => {
if (a.length !== b.length) return false;
return a.every((meta, i) => {
const other = b[i];
// Compare all fields that affect sidebar display.
// If you add a new display-relevant field to WorkspaceMetadata,
// add it to getWorkspaceSidebarKey() in src/browser/utils/workspace.ts
return other && getWorkspaceSidebarKey(meta) === getWorkspaceSidebarKey(other);
});
}),
[userProjects, workspaceMetadata, workspaceRecency]
);

const handleNavigateWorkspace = useCallback(
(direction: "next" | "prev") => {
// Read actual rendered workspace order from DOM — impossible to drift from sidebar.
const visibleIds = getVisibleWorkspaceIds();
if (visibleIds.length === 0) return;

const currentIndex = selectedWorkspace
? visibleIds.indexOf(selectedWorkspace.workspaceId)
: -1;

let targetIndex: number;
if (currentIndex === -1) {
targetIndex = direction === "next" ? 0 : visibleIds.length - 1;
} else if (direction === "next") {
targetIndex = (currentIndex + 1) % visibleIds.length;
} else {
targetIndex = currentIndex === 0 ? visibleIds.length - 1 : currentIndex - 1;
}

const targetMeta = workspaceMetadata.get(visibleIds[targetIndex]);
if (targetMeta) setSelectedWorkspace(toWorkspaceSelection(targetMeta));
},
[selectedWorkspace, workspaceMetadata, setSelectedWorkspace]
);

// Register command sources with registry
const {
registerSource,
getActions: getCommandActions,
isOpen: isCommandPaletteOpen,
open: openCommandPalette,
close: closeCommandPalette,
} = useCommandRegistry();

/**
* The workspace's effective AI selection, preserving explicit gateway prefixes. A sub-agent
* resolves for its own agent.
*/
const getAiSelectionForWorkspace = useCallback(
(workspaceId: string) => {
const metadata = workspaceMetadata.get(workspaceId);
return getWorkspaceAiSelection(
workspaceId,
metadata?.parentWorkspaceId != null && metadata.agentId ? metadata.agentId : undefined
);
},
[workspaceMetadata]
);
const getModelForWorkspace = useCallback(
(workspaceId: string): string => getAiSelectionForWorkspace(workspaceId).model,
[getAiSelectionForWorkspace]
);
const getThinkingLevelForWorkspace = (workspaceId: string): ThinkingLevel =>
workspaceId ? getAiSelectionForWorkspace(workspaceId).thinkingLevel : "off";
const getReasoningModeForWorkspace = (workspaceId: string): OpenAIReasoningMode =>
workspaceId ? getAiSelectionForWorkspace(workspaceId).reasoningMode : "standard";

// Pro mode is Responses-only; the palette command hides under chatCompletions
// and on non-passthrough routes (mirroring the send path's header gating).
const {
config: providersConfig,
refresh: refreshProvidersConfig,
updateOptimistically,
} = useProvidersConfig();
const routing = useRouting();
const getRouteForModel = useCallback(
(canonicalModel: string) => routing.resolveRoute(canonicalModel).route,
[routing]
);

// Palette picks stay in memory until a user message sends them.
const setThinkingLevelFromPalette = useCallback(
(workspaceId: string, level: ThinkingLevel) => {
if (!workspaceId) {
return;
}

const normalized = THINKING_LEVELS.includes(level) ? level : "off";
markAiSelectionIntent(workspaceId, "thinkingLevel", normalized);
// The palette bypasses ThinkingProvider.setThinkingLevel, so leave Auto here too.
setAutoRoutingChoice(workspaceId, "thinkingLevel", false);

if (api) {
// Mid-turn change: also apply to the active turn's next model step so
// the palette/keybind path behaves like the selector (ThinkingProvider).
requestActiveTurnThinkingLevel(api, workspaceId, normalized);
}

// Dispatch toast notification event for UI feedback
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, {
detail: { workspaceId, level: normalized },
})
);
}
},
[api]
);

const toggleReasoningModeFromPalette = (
workspaceId: string,
mode: Exclude<OpenAIReasoningMode, "standard">
) => {
if (!workspaceId) {
return;
}
const next: OpenAIReasoningMode =
getReasoningModeForWorkspace(workspaceId) === mode ? "standard" : mode;
markAiSelectionIntent(workspaceId, "reasoningMode", next);
};

const getFastModeActive = useCallback(() => {
const scopeId = selectedWorkspace?.workspaceId ?? creationScopeId;
if (!scopeId || providersConfig == null) return false;

const model = getModelForWorkspace(scopeId);
const provider = getFastModeProvider(model, {
providersConfig,
resolvedRouteProvider: getRouteForModel(normalizeToCanonical(model)),
});
return provider === "openai"
? (getWorkspaceAiSelection(scopeId).serviceTier ?? providersConfig.openai?.serviceTier) ===
"priority"
: provider != null && isFastModeActive(provider, providersConfig[provider]);
}, [creationScopeId, getModelForWorkspace, getRouteForModel, providersConfig, selectedWorkspace]);

// Plain function: React Compiler handles memoization.
const getUltrafastModeActive = () => {
const scopeId = selectedWorkspace?.workspaceId ?? creationScopeId;
if (!scopeId || providersConfig == null) return false;

const model = getModelForWorkspace(scopeId);
return (
ultrafastModeAvailable(model, {
providersConfig,
resolvedRouteProvider: getRouteForModel(normalizeToCanonical(model)),
}) &&
(getWorkspaceAiSelection(scopeId).serviceTier ?? providersConfig.openai?.serviceTier) ===
"ultrafast"
);
};

const fastModeToggleInFlightRef = useRef(false);
// Native host computer use belongs to the selected workspace. Offer it only where the backend
// accepts it: in the desktop app, for workspaces that run on this machine.
const computerUse = useComputerUse(selectedWorkspace?.workspaceId ?? null);
const selectedRuntimeConfig =
selectedWorkspace == null
? undefined
: workspaceMetadata.get(selectedWorkspace.workspaceId)?.runtimeConfig;
const computerUseAvailable =
computerUse.status?.supported === true &&
(isWorktreeRuntime(selectedRuntimeConfig) || isLocalProjectRuntime(selectedRuntimeConfig));

// OpenAI speed is scoped to the chat; other providers retain their existing Fast preferences.
const togglePremiumServiceTier = useCallback(
async (targetServiceTier: PremiumServiceTier) => {
const scopeId = selectedWorkspace?.workspaceId ?? creationScopeId;
if (!api || !scopeId || providersConfig == null || fastModeToggleInFlightRef.current) return;
if (!liveApi || !scopeId || providersConfig == null || fastModeToggleInFlightRef.current)
return;

// Creation composers use the same project-scoped model preference as their selector,
// so the global shortcut remains available before the first workspace exists.
// Serialize requests so a quick double press cannot compute two writes from stale config.
fastModeToggleInFlightRef.current = true;
const model = getModelForWorkspace(scopeId);
const availabilityOptions = {
providersConfig,
resolvedRouteProvider: getRouteForModel(normalizeToCanonical(model)),
};
const provider = getFastModeProvider(model, availabilityOptions);
if (
targetServiceTier === "ultrafast" &&
!ultrafastModeAvailable(model, availabilityOptions)
) {
fastModeToggleInFlightRef.current = false;
window.dispatchEvent(
createCustomEvent(CUSTOM_EVENTS.ANALYTICS_REBUILD_TOAST, {
type: "error",
title: "Ultrafast mode",
message: `Ultrafast mode is not available for ${model} on its current provider route and wire format.`,
})
);
return;
}
if (provider == null) {
fastModeToggleInFlightRef.current = false;
// The shortcut used to do nothing here, so it looked broken (#5693). Say why instead.
window.dispatchEvent(
createCustomEvent(CUSTOM_EVENTS.ANALYTICS_REBUILD_TOAST, {
type: "error",
title: "Fast mode",
message:
getFastModeUnavailableReason(model, providersConfig) === "model"
? `Fast mode is not available for ${model}: this model has no fast mode.`
: `Fast mode is not available for ${model} on its current provider route, for example through a gateway or a custom base URL.`,
})
);
return;
}

try {
if (provider === "openai") {
const current =
getWorkspaceAiSelection(scopeId).serviceTier ?? providersConfig.openai?.serviceTier;
setWorkspaceServiceTier(api, scopeId, toggleServiceTier(current, targetServiceTier));
setWorkspaceServiceTier(liveApi, scopeId, toggleServiceTier(current, targetServiceTier));
return;
}
const patch = await applyFastModeToggle(
api.providers,
liveApi.providers,
provider,
providersConfig[provider],
targetServiceTier
);
if (patch) {
updateOptimistically(provider, patch);
} else {
await refreshProvidersConfig();
}
} catch {
await refreshProvidersConfig();
} finally {
fastModeToggleInFlightRef.current = false;
}
},
[
api,
liveApi,
creationScopeId,
getModelForWorkspace,
getRouteForModel,
providersConfig,
refreshProvidersConfig,
selectedWorkspace,
updateOptimistically,
]
);

const registerParamsRef = useRef<BuildSourcesParams | null>(null);

const openNewScratchFromPalette = useCallback(() => {
createWorkspaceDraft(SCRATCH_PROJECT_CONFIG_KEY);
}, [createWorkspaceDraft]);

const openNewWorkspaceFromPalette = useCallback(
(projectPath: string) => {
startWorkspaceCreation(projectPath);
},
[startWorkspaceCreation]
);

const openNewMultiProjectWorkspaceFromPalette = useCallback(() => {
if (!multiProjectWorkspacesEnabled) {
return;
}
setMultiProjectWorkspaceModalOpen(true);
}, [multiProjectWorkspacesEnabled]);

useEffect(() => {
if (multiProjectWorkspacesEnabled || !isMultiProjectWorkspaceModalOpen) {
return;
}

setMultiProjectWorkspaceModalOpen(false);
}, [isMultiProjectWorkspaceModalOpen, multiProjectWorkspacesEnabled]);

const createMultiProjectWorkspace = useCallback(
async (projectPaths: string[]) => {
assert(
projectPaths.length >= 2,
"createMultiProjectWorkspace requires at least two projects"
);
if (!api) {
if (!liveApi) {
throw new Error("Not connected to server");
}

const [primaryProjectPath] = projectPaths;
assert(primaryProjectPath, "createMultiProjectWorkspace requires a primary project");

const branchResult = await api.projects.listBranches({ projectPath: primaryProjectPath });
const branchResult = await liveApi.projects.listBranches({ projectPath: primaryProjectPath });
const candidateBranches = branchResult.branches.filter(
(branch): branch is string => typeof branch === "string"
);
const trunkBranch =
(branchResult.recommendedTrunk && candidateBranches.includes(branchResult.recommendedTrunk)
? branchResult.recommendedTrunk
: candidateBranches[0]) ?? null;
if (!trunkBranch) {
const primaryProjectName =
primaryProjectPath.split("/").pop() ??
primaryProjectPath.split("\\").pop() ??
primaryProjectPath;
throw new Error(
`No branches found for ${primaryProjectName}. Initialize a git repository before creating a multi-project workspace.`
);
}

// Multi-project container names must stay unique and path-separator-free.
const projects = createProjectRefs(projectPaths);

const metadata = await api.workspace.createMultiProject({
const metadata = await liveApi.workspace.createMultiProject({
projects,
branchName: `multi-${Date.now().toString(36)}`,
trunkBranch,
});

// Make the synthetic section visible immediately after creation.
updatePersistedState<string[]>(
EXPANDED_PROJECTS_KEY,
(prev) => {
const expanded = Array.isArray(prev) ? prev : [];
return expanded.includes(MULTI_PROJECT_SIDEBAR_SECTION_ID)
? expanded
: [...expanded, MULTI_PROJECT_SIDEBAR_SECTION_ID];
},
[]
);

// Inline workspace creation bookkeeping (main removed the extracted callback)
workspaceStore.addWorkspace(metadata);
setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata));
telemetry.workspaceCreated(metadata.id, getRuntimeTypeForTelemetry(metadata.runtimeConfig));
setSelectedWorkspace(toWorkspaceSelection(metadata));
setMultiProjectWorkspaceModalOpen(false);
},
[api, setSelectedWorkspace, setWorkspaceMetadata, telemetry, workspaceStore]
[liveApi, setSelectedWorkspace, setWorkspaceMetadata, telemetry, workspaceStore]
);

const archiveMergedWorkspacesInProjectFromPalette = useCallback(
async (projectPath: string): Promise<void> => {
const trimmedProjectPath = projectPath.trim();
if (!trimmedProjectPath) return;

if (!api) {
if (!liveApi) {
if (typeof window !== "undefined") {
window.alert("Cannot archive merged workspaces: API not connected");
}
return;
}

try {
const result = await api.workspace.archiveMergedInProject({
const result = await liveApi.workspace.archiveMergedInProject({
projectPath: trimmedProjectPath,
});

if (!result.success) {
if (typeof window !== "undefined") {
window.alert(result.error);
}
return;
}

const errorCount = result.data.errors.length;
if (errorCount > 0) {
const archivedCount = result.data.archivedWorkspaceIds.length;
const skippedCount = result.data.skippedWorkspaceIds.length;

const MAX_ERRORS_TO_SHOW = 5;
const shownErrors = result.data.errors
.slice(0, MAX_ERRORS_TO_SHOW)
.map((e) => `- ${e.workspaceId}: ${e.error}`)
.join("\n");
const remainingCount = Math.max(0, errorCount - MAX_ERRORS_TO_SHOW);
const remainingSuffix = remainingCount > 0 ? `\n… and ${remainingCount} more.` : "";

if (typeof window !== "undefined") {
window.alert(
`Archived merged workspaces with some errors.\n\nArchived: ${archivedCount}\nSkipped: ${skippedCount}\nErrors: ${errorCount}\n\nErrors:\n${shownErrors}${remainingSuffix}`
);
}
}
} catch (error) {
const message = getErrorMessage(error);
if (typeof window !== "undefined") {
window.alert(message);
}
}
},
[api]
[liveApi]
);

const getBranchesForProject = useCallback(
async (projectPath: string): Promise<BranchListResult> => {
if (!api) {
if (!liveApi) {
return { branches: [], recommendedTrunk: null };
}
const branchResult = await api.projects.listBranches({ projectPath });
const branchResult = await liveApi.projects.listBranches({ projectPath });
const sanitizedBranches = branchResult.branches.filter(
(branch): branch is string => typeof branch === "string"
);

const recommended =
branchResult.recommendedTrunk && sanitizedBranches.includes(branchResult.recommendedTrunk)
? branchResult.recommendedTrunk
: (sanitizedBranches[0] ?? null);

return {
branches: sanitizedBranches,
recommendedTrunk: recommended,
};
},
[api]
[liveApi]
);

const selectWorkspaceFromPalette = useCallback(
(selection: WorkspaceSelection) => {
setSelectedWorkspace(selection);
},
[setSelectedWorkspace]
);

// The palette has no anchor element, so refusals (e.g. the cross-backend structural-mutation
// gate, #4865) use the anchorless popover error; otherwise they only reach the renderer log.
const paletteRemoveError = usePopoverError();
// The removed sub-agent's row is gone, so an auto-dismissing popover would leave no way to
// reread what its removal left behind (#5143, #5190).
const paletteRemoveWarning = usePopoverError(null);
const removeWorkspaceFromPalette = useCallback(
async (workspaceId: string) => {
const result = await removeWorkspace(workspaceId);
if (!result.success) {
paletteRemoveError.showError(workspaceId, result.error ?? "Failed to remove workspace");
}
return result;
},
[removeWorkspace, paletteRemoveError]
);
const removeSubagentFromPalette = async (workspaceId: string, title: string) => {
const notice = await confirmAndRemoveSubagent({
api,
getApi: () => liveApiRef.current,
confirm: confirmDialog,
removeSubagent,
workspaceId,
title,
});
if (notice == null) return;
const popover = notice.kind === "error" ? paletteRemoveError : paletteRemoveWarning;
popover.showError(workspaceId, notice.message);
};

const updateTitleFromPalette = useCallback(
async (workspaceId: string, newTitle: string) => updateWorkspaceTitle(workspaceId, newTitle),
[updateWorkspaceTitle]
);

const addProjectFromPalette = useCallback(() => {
openProjectCreateModal();
}, [openProjectCreateModal]);

const removeProjectFromPalette = useCallback(
(path: string) => {
void handleRemoveProject(path);
},
[handleRemoveProject]
);

const toggleSidebarFromPalette = useCallback(() => {
setSidebarCollapsed((prev) => !prev);
}, [setSidebarCollapsed]);

const navigateWorkspaceFromPalette = useCallback(
(dir: "next" | "prev") => {
handleNavigateWorkspace(dir);
},
[handleNavigateWorkspace]
);

// Move the selected pinned chat within its visual pinned block. Shares the
// move-order helper with the sidebar keybind handler so palette and keyboard
// behavior can't drift apart.
const movePinnedChatFromPalette = useCallback(
(direction: PinnedMoveDirection) => {
if (!selectedWorkspace) return;
const meta = workspaceMetadata.get(selectedWorkspace.workspaceId);
if (!meta) return;
const order = computePinnedMoveOrderForWorkspace(
meta,
direction,
sortedWorkspacesByProject,
userProjects,
getUserPreferences().ui?.sidebarFlatMode
? { multiProjectEnabled: multiProjectWorkspacesEnabled }
: false
);
if (order) void reorderPinnedWorkspaces(order);
},
[
selectedWorkspace,
workspaceMetadata,
sortedWorkspacesByProject,
userProjects,
multiProjectWorkspacesEnabled,
reorderPinnedWorkspaces,
]
);

registerParamsRef.current = {
userProjects,
workspaceMetadata,
selectedWorkspace,
creationScopeId,
themePreference,
getThinkingLevel: getThinkingLevelForWorkspace,
onSetThinkingLevel: setThinkingLevelFromPalette,
getReasoningMode: getReasoningModeForWorkspace,
onToggleReasoningMode: toggleReasoningModeFromPalette,
getFastMode: getFastModeActive,
onToggleFastMode: () => togglePremiumServiceTier("priority"),
getUltrafastMode: getUltrafastModeActive,
onToggleUltrafastMode: () => togglePremiumServiceTier("ultrafast"),
// Read when the palette opens, from the shared cache (one fetch + one config subscription
// per session), so showing the state costs no IPC call (#5791).
getKeepScreenAwake: () => getAppConfigStore().getSnapshot()?.keepScreenAwake,
computerUse: computerUseAvailable
? { enabled: computerUse.enabledHere, onToggle: computerUse.toggle }
: null,
autoModelRoutingEnabled,
// The composer's useAutoRoutingSelection reads the same picks, so a palette write lands
// in the picker rows the way a row click does.
getAutoRouting: (scopeId, dimension) => getAutoRouting(scopeId, dimension),
onSetAutoRouting: (scopeId, dimension, active) =>
setAutoRoutingChoice(scopeId, dimension, active),
getEffectiveComposerModel: getModelForWorkspace,
providersConfig,
getRouteForModel,
getEffectiveRouteForModel: (modelString) => routing.resolveEffectiveRoute(modelString),
getMinThinkingOverride,
onStartScratchCreation: openNewScratchFromPalette,
onStartWorkspaceCreation: openNewWorkspaceFromPalette,
onStartMultiProjectWorkspaceCreation: openNewMultiProjectWorkspaceFromPalette,
multiProjectWorkspacesEnabled,
artifactsEnabled,
perfFlightRecorderEnabled,
sessionTapesEnabled,
onArchiveMergedWorkspacesInProject: archiveMergedWorkspacesInProjectFromPalette,
getBranchesForProject,
onSelectWorkspace: selectWorkspaceFromPalette,
onRemoveWorkspace: removeWorkspaceFromPalette,
onRemoveSubagent: removeSubagentFromPalette,
onUpdateTitle: updateTitleFromPalette,
onAddProject: addProjectFromPalette,
onRemoveProject: removeProjectFromPalette,
onToggleSidebar: toggleSidebarFromPalette,
onNavigateWorkspace: navigateWorkspaceFromPalette,
onMovePinnedChat: movePinnedChatFromPalette,
onOpenWorkspaceInTerminal: (workspaceId, runtimeConfig) => {
// Best-effort only. Palette actions should never throw.
void openWorkspaceInTerminal(workspaceId, runtimeConfig).catch(() => {
// Errors are surfaced elsewhere (toasts/logs) and users can retry.
});
},
onToggleTheme: toggleTheme,
onSetTheme: setThemePreference,
onOpenSettings: openSettings,
onOpenAbout: openAboutDialog,
remoteConnection: window.api?.remoteConnection,
supportedUpdateChannels,
layoutPresets,
onApplyLayoutSlot: (workspaceId, slot) => {
void applySlotToWorkspace(workspaceId, slot).catch(() => {
// Best-effort only.
});
},
onCaptureLayoutSlot: async (workspaceId, slot, name) => {
try {
const result = await saveCurrentWorkspaceToSlot(workspaceId, slot, name);
// A refused capture (nothing saveable open) reaches the palette, which shows it.
return result.success ? Ok(undefined) : Err(result.error);
} catch {
return Err("Failed to save layout.");
}
},
onClearTimingStats: (workspaceId: string) => workspaceStore.clearTimingStats(workspaceId),
api,
api: liveApi,
confirmDialog,
};

useEffect(() => {
const unregister = registerSource(() => {
const params = registerParamsRef.current;
if (!params) return [];

// Compute streaming models here (only when command palette opens)
const allStates = workspaceStore.getAllStates();
const selectedWorkspaceState = params.selectedWorkspace
? (allStates.get(params.selectedWorkspace.workspaceId) ?? null)
: null;
const streamingModels = new Map<string, string>();
for (const [workspaceId, state] of allStates) {
if (state.canInterrupt && state.currentModel) {
streamingModels.set(workspaceId, state.currentModel);
}
}

const factories = buildCoreSources({
...params,
streamingModels,
selectedWorkspaceState,
});
const actions: CommandAction[] = [];
for (const factory of factories) {
actions.push(...factory());
}
return actions;
});
return unregister;
}, [registerSource, workspaceStore]);

// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) {
return;
}

if (matchesKeybind(e, KEYBINDS.NEW_SCRATCH_CHAT)) {
e.preventDefault();
openNewScratchFromPalette();
} else if (matchesKeybind(e, KEYBINDS.NEXT_WORKSPACE)) {
e.preventDefault();
handleNavigateWorkspace("next");
} else if (matchesKeybind(e, KEYBINDS.PREV_WORKSPACE)) {
e.preventDefault();
handleNavigateWorkspace("prev");
} else if (
matchesKeybind(e, KEYBINDS.OPEN_COMMAND_PALETTE) ||
matchesKeybind(e, KEYBINDS.OPEN_COMMAND_PALETTE_ACTIONS)
) {
e.preventDefault();
if (isCommandPaletteOpen) {
closeCommandPalette();
} else if (!isDialogOpen()) {
// Alternate palette shortcut opens in command mode (with ">") while the
// primary Ctrl/Cmd+Shift+P shortcut opens default workspace-switch mode.
const initialQuery = matchesKeybind(e, KEYBINDS.OPEN_COMMAND_PALETTE_ACTIONS)
? ">"
: undefined;
openCommandPalette(initialQuery);
}
} else if (matchesKeybind(e, KEYBINDS.TOGGLE_FAST_MODE)) {
e.preventDefault();
// Modals trap focus and hide the page behind them; don't change hidden page UI.
if (!isDialogOpen()) togglePremiumServiceTier("priority").catch(() => undefined);
} else if (matchesKeybind(e, KEYBINDS.TOGGLE_ULTRAFAST_MODE)) {
e.preventDefault();
// A held chord would re-toggle once each write settles, leaving the tier timing-dependent.
if (!isDialogOpen() && !e.repeat) {
togglePremiumServiceTier("ultrafast").catch(() => undefined);
}
} else if (matchesKeybind(e, KEYBINDS.TOGGLE_COMPUTER_USE) && computerUseAvailable) {
e.preventDefault();
// A held shortcut would race enable and disable requests and leave either state behind.
if (!e.repeat && !isDialogOpen()) computerUse.toggle().catch(() => undefined);
} else if (matchesKeybind(e, KEYBINDS.TOGGLE_SIDEBAR)) {
e.preventDefault();
if (!isDialogOpen()) setSidebarCollapsed((prev) => !prev);
} else if (matchesKeybind(e, KEYBINDS.OPEN_SETTINGS)) {
e.preventDefault();
// An open palette would stay behind the settings modal and swallow its first Escape.
closeCommandPalette();
openSettings();
} else if (matchesKeybind(e, KEYBINDS.OPEN_SERVER_WINDOW) && window.api?.remoteConnection) {
// Desktop-only: browser mode and server windows have no remote connection bridge.
e.preventDefault();
openServerWindow(window.api.remoteConnection, openSettings).catch(() => undefined);
} else if (matchesKeybind(e, KEYBINDS.OPEN_ANALYTICS)) {
e.preventDefault();
// With settings stacked over analytics, the toggle returns to analytics (navigateToAnalytics
// closes settings), matching how it opens analytics from settings over a plain page.
if (isAnalyticsOpen && !isSettingsOpen) {
navigateFromAnalytics();
} else {
// An open palette would stay behind the analytics modal and swallow its first Escape.
closeCommandPalette();
navigateToAnalytics();
}
} else if (matchesKeybind(e, KEYBINDS.REPORT_SLOWNESS)) {
e.preventDefault();
// Runs the palette action, so the experiment gating lives in one place: while the
// experiment is off the action is absent and the shortcut does nothing.
// Holding the chord must not write one bundle per key auto-repeat.
if (!e.repeat && !isDialogOpen()) {
const action = getCommandActions().find(
(candidate) => candidate.id === CommandIds.perfReportSlowness()
);
// The action reports its own outcome in a toast and never throws.
if (action) Promise.resolve(action.run()).catch(() => undefined);
}
} else if (
matchesKeybind(e, KEYBINDS.SAVE_SESSION_TAPES) ||
matchesKeybind(e, KEYBINDS.REVEAL_SESSION_TAPES)
) {
e.preventDefault();
if (e.repeat || isDialogOpen()) return;
// Run the registered palette action so the sessionTapes experiment gate lives in one
// place: with the experiment off there is no action and the shortcut does nothing.
const actionId = matchesKeybind(e, KEYBINDS.SAVE_SESSION_TAPES)
? CommandIds.sessionTapesSave()
: CommandIds.sessionTapesReveal();
const action = getCommandActions().find((candidate) => candidate.id === actionId);
if (action) Promise.resolve(action.run()).catch(() => undefined);
} else if (matchesKeybind(e, KEYBINDS.NAVIGATE_BACK)) {
e.preventDefault();
navigateBack();
} else if (matchesKeybind(e, KEYBINDS.NAVIGATE_FORWARD)) {
e.preventDefault();
navigateForward();
}
};

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
openNewScratchFromPalette,
handleNavigateWorkspace,
setSidebarCollapsed,
isCommandPaletteOpen,
closeCommandPalette,
openCommandPalette,
togglePremiumServiceTier,
computerUseAvailable,
computerUse,
openSettings,
isSettingsOpen,
isAnalyticsOpen,
navigateToAnalytics,
navigateFromAnalytics,
navigateBack,
navigateForward,
getCommandActions,
]);
// The native menu's Open Server Window item forwards here, so it shares the shortcut's flow.
useEffect(() => {
const bridge = window.api?.remoteConnection;
if (!bridge) return;
return bridge.onOpenServerWindowRequested(() => {
openServerWindow(bridge, openSettings).catch(() => undefined);
});
}, [openSettings]);

// Mouse back/forward buttons (buttons 3 and 4)
useEffect(() => {
// In a browser tab these buttons are the browser's own Back and Forward, which now move
// through app history. Handling them here too could step twice where the browser ignores
// preventDefault on mousedown.
if (usesBrowserHistory) return;
const handleMouseNavigation = (e: MouseEvent) => {
if (e.button === 3) {
e.preventDefault();
navigateBack();
} else if (e.button === 4) {
e.preventDefault();
navigateForward();
}
};

// Capture phase fires before Chrome's default back/forward handling
window.addEventListener("mousedown", handleMouseNavigation, true);
return () => window.removeEventListener("mousedown", handleMouseNavigation, true);
}, [usesBrowserHistory, navigateBack, navigateForward]);

useEffect(() => {
// Only needed in standalone PWA mode — normal browser tabs should have standard back/forward behavior
const isStandalone = window.matchMedia("(display-mode: standalone)").matches;
if (window.api || !isStandalone) return;

// Push a dummy state so back button has somewhere to go without leaving the app
window.history.pushState({ mux: true }, "", window.location.href);

const handlePopState = () => {
// Re-push the correct URL from MemoryRouter, not the popped browser URL
const { pathname, search, hash } = locationRef.current;
const correctUrl = new URL(
prependInitialAppProxyBasePath(`${pathname}${search}${hash}`),
window.location.origin
);
window.history.pushState({ mux: true }, "", correctUrl);
};

window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, []);

// Layout slot hotkeys (Ctrl/Cmd+Alt+1..9 by default)
useEffect(() => {
const handleKeyDownCapture = (e: KeyboardEvent) => {
handleLayoutSlotHotkeys(e, {
isCommandPaletteOpen,
// Layout slots change the hidden page, so they stay inert under either modal.
isSettingsOpen: isPageCoveredByModal,
selectedWorkspaceId: selectedWorkspace?.workspaceId ?? null,
layoutPresets,
applySlotToWorkspace,
});
};

window.addEventListener("keydown", handleKeyDownCapture, { capture: true });
return () => window.removeEventListener("keydown", handleKeyDownCapture, { capture: true });
}, [
isCommandPaletteOpen,
isPageCoveredByModal,
selectedWorkspace,
layoutPresets,
applySlotToWorkspace,
]);

// Subscribe to menu bar "Open Settings" (macOS Cmd+, from app menu)
useEffect(() => {
if (!api) return;

const abortController = new AbortController();
const signal = abortController.signal;

(async () => {
try {
const iterator = await api.menu.onOpenSettings(undefined, { signal });
for await (const _ of iterator) {
if (signal.aborted) break;
closeCommandPalette();
openSettings();
}
} catch {
// Subscription cancelled via abort signal - expected on cleanup
}
})();

return () => abortController.abort();
}, [api, openSettings, closeCommandPalette]);

// Handle workspace fork switch event
useEffect(() => {
const handleForkSwitch = (e: Event) => {
if (!isWorkspaceForkSwitchEvent(e)) return;

const workspaceInfo = e.detail;

// Ensure the workspace's project is present in the sidebar config.
//
// IMPORTANT: don't early-return here. In practice this event can fire before
// ProjectContext has finished loading (or before a refresh runs), and returning
// would make the forked workspace appear "missing" until a later refresh.
const project = userProjects.get(workspaceInfo.projectPath);
if (!project) {
console.warn(
`[Frontend] Project not found for forked workspace path: ${workspaceInfo.projectPath} (will refresh)`
);
void refreshProjects();
}

// DEFENSIVE: Ensure createdAt exists
if (!workspaceInfo.createdAt) {
console.warn(
`[Frontend] Workspace ${workspaceInfo.id} missing createdAt in fork switch - using default (2025-01-01)`
);
workspaceInfo.createdAt = "2025-01-01T00:00:00.000Z";
}

// Update metadata Map immediately (don't wait for async metadata event)
// This ensures the title bar effect has the workspace name available
setWorkspaceMetadata((prev) => {
const updated = new Map(prev);
updated.set(workspaceInfo.id, workspaceInfo);
return updated;
});

// Switch to the new workspace
setSelectedWorkspace(toWorkspaceSelection(workspaceInfo));
};

window.addEventListener(CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH, handleForkSwitch as EventListener);
return () =>
window.removeEventListener(
CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH,
handleForkSwitch as EventListener
);
}, [userProjects, refreshProjects, setSelectedWorkspace, setWorkspaceMetadata]);

// Set up navigation callback for notification clicks
useEffect(() => {
const navigateToWorkspace = (workspaceId: string) => {
const metadata = workspaceMetadataRef.current.get(workspaceId);
if (metadata) {
setSelectedWorkspace(toWorkspaceSelection(metadata));
}
};

// Single source of truth: WorkspaceStore owns the navigation callback.
// Browser notifications and Electron notification clicks both route through this.
workspaceStore.setNavigateToWorkspace(navigateToWorkspace);

// Callback for "notify on response" feature - fires when any assistant response completes.
// Only notify when isFinal=true (assistant done with all work, no more active streams).
// finalText is extracted by the aggregator (text after tool calls).
// completion carries notification-policy metadata (compaction vs normal response,
// and whether another queued/auto follow-up will immediately take over).
const handleResponseComplete = (event: ResponseCompleteEvent) => {
// Only notify on final message (when assistant is done with all work).
if (!event.isFinal) {
return;
}

// Only mark read when the user is actively viewing this workspace's chat.
// A non-chat route or the settings/analytics modal hides the chat even though
// the workspace remains "selected".
const isChatVisible =
document.hasFocus() && visibleChatWorkspaceIdRef.current === event.workspaceId;
if (event.completedAt != null && isChatVisible) {
updatePersistedState(getWorkspaceLastReadKey(event.workspaceId), event.completedAt);
}

if (!shouldNotifyOnResponseComplete(event.completion)) {
return;
}

// Skip notification if the selected workspace is focused (Slack-like behavior).
// Notification suppression follows selection state, except that a modal covers the chat.
const isWorkspaceFocused =
document.hasFocus() &&
!isPageCoveredByModalRef.current &&
selectedWorkspaceRef.current?.workspaceId === event.workspaceId;
if (isWorkspaceFocused) {
return;
}
// /side chats are short-lived questions answered next to (or in place of) their main chat,
// which already has the user's attention; they never notify (they inherit the main chat's
// notify setting through copied storage, and a click would open the side chat full-screen).
if (workspaceMetadataRef.current.get(event.workspaceId)?.sideChatParentWorkspaceId != null) {
return;
}

// Check if notifications are enabled for this workspace.
const notifyEnabled =
getUserPreferences().notifications?.notifyOnResponseByWorkspace?.[event.workspaceId] ===
true;
if (!notifyEnabled) {
return;
}

const metadata = workspaceMetadataRef.current.get(event.workspaceId);
const title = metadata?.title ?? metadata?.name ?? "Response complete";
const body = getResponseCompleteNotificationBody(event.finalText, event.completion);

showBrowserNotification(title, {
body,
onClick: () => {
window.focus();
navigateToWorkspace(event.workspaceId);
},
});
};

workspaceStore.setOnResponseComplete(handleResponseComplete);

const unsubscribe = window.api?.onNotificationClicked?.((data) => {
workspaceStore.navigateToWorkspace(data.workspaceId);
});

return () => {
unsubscribe?.();
};
}, [setSelectedWorkspace, workspaceStore]);

// Show auth modal if authentication is required
if (status === "auth_required") {
return (
<AuthTokenModal
isOpen={true}
onSubmit={authenticate}
onSessionAuthenticated={retry}
error={error}
/>
);
}

return (
<>
<div
className="bg-surface-primary mobile-layout flex h-full overflow-hidden pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pl-[env(safe-area-inset-left)]"
ref={appShellRef}
>
<LeftSidebar
collapsed={sidebarCollapsed}
onToggleCollapsed={handleToggleSidebar}
widthPx={leftSidebar.width}
isResizing={leftSidebar.isResizing}
onStartResize={leftSidebar.startResize}
sortedWorkspacesByProject={sortedWorkspacesByProject}
workspaceRecency={workspaceRecency}
/>
<div className="mobile-main-content flex min-w-0 flex-1 flex-col overflow-hidden">
<WindowsToolchainBanner />
<RosettaBanner />
<PageArea
pageKey={
selectedWorkspace || currentWorkspaceId
? "workspace"
: creationProjectPath === SCRATCH_PROJECT_CONFIG_KEY
? "scratch"
: creationProjectPath
? "project"
: "root"
}
>
{/* Settings and analytics are modals over whatever page they were opened from, so the
page stays mounted underneath them. */}
{selectedWorkspace ? (
(() => {
const currentMetadata = workspaceMetadata.get(selectedWorkspace.workspaceId);
// Guard: Don't render AIView if workspace metadata not found.
// This can happen when selectedWorkspace (from localStorage) refers to a
// deleted workspace, or during a race condition on reload before the
// validation effect clears the stale selection.
if (!currentMetadata) {
return null;
}
// Use metadata.name for workspace name (works for both worktree and local runtimes)
// Fallback to path-based derivation for legacy compatibility
const workspaceName =
currentMetadata.name ??
selectedWorkspace.namedWorkspacePath?.split("/").pop() ??
selectedWorkspace.workspaceId;
// Use live metadata path (updates on rename) with fallback to initial path
const workspacePath =
currentMetadata.namedWorkspacePath ?? selectedWorkspace.namedWorkspacePath ?? "";
return (
<AIView
errorScope={`${selectedWorkspace.projectName}/${workspaceName}`}
workspaceId={selectedWorkspace.workspaceId}
projectPath={selectedWorkspace.projectPath}
projectName={selectedWorkspace.projectName}
leftSidebarCollapsed={sidebarCollapsed}
onToggleLeftSidebarCollapsed={handleToggleSidebar}
workspaceName={workspaceName}
namedWorkspacePath={workspacePath}
runtimeConfig={currentMetadata.runtimeConfig}
incompatibleRuntime={currentMetadata.incompatibleRuntime}
isInitializing={currentMetadata.isInitializing === true}
/>
);
})()
) : currentWorkspaceId ? (
loading ? (
// Keep a lightweight loading shell while WorkspaceContext validates or
// self-heals stale `/workspace/:id` routes. Restoring a path alone is not
// enough; we only render AIView once metadata hydration confirms it exists.
// `main`: this screen replaces the workspace page, so it carries the page's one
// `main` landmark (Lighthouse landmark-one-main, #5998). LoadingScreen itself stays
// landmark-free, because the boot path in AppLoader shares it. The same direction
// rule as the parent (`mobile-layout`: a row on desktop, a column on phones), so
// the 100vw/100dvh loader shrinks to the same space it had without the wrapper.
<main className="mobile-layout flex min-w-0 flex-1 overflow-hidden">
<LoadingScreen statusText="Opening workspace..." />
</main>
) : (
// If metadata never hydrated for the routed workspace, avoid trapping the user
// on a permanent spinner. Show the same non-blocking root shell while the route
// waits for a later retry or manual navigation.
<RootRouteShell
leftSidebarCollapsed={sidebarCollapsed}
onToggleLeftSidebarCollapsed={handleToggleSidebar}
/>
)
) : creationProjectPath === SCRATCH_PROJECT_CONFIG_KEY ? (
<ScratchPage
leftSidebarCollapsed={sidebarCollapsed}
onToggleLeftSidebarCollapsed={handleToggleSidebar}
pendingDraftId={pendingNewWorkspaceDraftId}
onWorkspaceCreated={handleWorkspaceCreated}
/>
) : creationProjectPath ? (
<ProjectPage
projectPath={creationProjectPath}
projectName={
creationProjectPath.split("/").pop() ??
creationProjectPath.split("\\").pop() ??
"Project"
}
leftSidebarCollapsed={sidebarCollapsed}
onToggleLeftSidebarCollapsed={handleToggleSidebar}
pendingSubProjectPath={pendingNewWorkspaceSubProjectPath}
pendingDraftId={pendingNewWorkspaceDraftId}
onWorkspaceCreated={handleWorkspaceCreated}
/>
) : (
// The dedicated Xum home page was removed. Keep `/` as a minimal shell so
// WorkspaceContext can redirect it to a concrete project route when possible,
// without reintroducing a sticky dashboard screen.
<RootRouteShell
leftSidebarCollapsed={sidebarCollapsed}
onToggleLeftSidebarCollapsed={handleToggleSidebar}
/>
)}
</PageArea>
</div>
<WorkspaceActiveGoalsWarningToast />
<CommandPalette getSlashContext={() => ({ workspaceId: selectedWorkspace?.workspaceId })} />
{/* Before SettingsPage so settings opened from analytics stacks above it. */}
<LazyAnalyticsDashboard />
<LazySettingsPage />
<PopoverError
error={paletteRemoveError.error}
prefix="Failed to remove workspace"
onDismiss={paletteRemoveError.clearError}
/>
{/* Same anchor as the error popover: the warning stays hidden while an error shows and
returns when it closes, so they never overlap and the warning is not lost (#5190). */}
<PopoverError
error={paletteRemoveError.error ? null : paletteRemoveWarning.error}
prefix="Sub-agent removed, but something was left behind"
onDismiss={paletteRemoveWarning.clearError}
/>
<ProjectCreateModal
initialPath={projectCreateInitialPath}
isOpen={isProjectCreateModalOpen}
onClose={closeProjectCreateModal}
onSuccess={(normalizedPath, projectConfig) => {
addProject(normalizedPath, projectConfig);
updatePersistedState(getAgentsInitNudgeKey(normalizedPath), true);
// Auto-expand new project in sidebar
updatePersistedState<string[]>(
EXPANDED_PROJECTS_KEY,
(prev) => [...(Array.isArray(prev) ? prev : []), normalizedPath],
[]
);
beginWorkspaceCreation(normalizedPath);
}}
/>
{multiProjectWorkspacesEnabled && (
<MultiProjectWorkspaceCreateModal
isOpen={isMultiProjectWorkspaceModalOpen}
onClose={() => setMultiProjectWorkspaceModalOpen(false)}
projectOptions={getTopLevelProjectEntries(userProjects).map(([projectPath]) => ({
projectPath,
projectName:
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "Unnamed Project",
}))}
onConfirm={createMultiProjectWorkspace}
/>
)}
<AboutDialog />
<MuxGatewaySessionExpiredDialog />
<SshPromptDialog />
<TerminalDialog />
</div>
</>
);
}

Check warning on line 1698 in src/browser/App.tsx

View workflow job for this annotation

GitHub Actions / Quality / CRAP

CRAP crap-tax (warning)

src/browser/App.tsx:251-1698 AppInner: touched hotspot, CC 246 unchanged: consider shrinking it

function App() {
return (
Expand Down
7 changes: 4 additions & 3 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
import type { TerminalSessionCreateOptions } from "@/browser/utils/terminal";
import { useAPI } from "@/browser/contexts/API";
import { useAPI, useLiveAPI } from "@/browser/contexts/API";
import { useChatTranscriptFullWidth } from "@/browser/hooks/useChatTranscriptFullWidth";
import { CHAT_DOCK_GUTTER_CLASS } from "@/constants/layout";
import { isTranscriptMutationAllowed } from "@/browser/utils/transcriptBarrier";
Expand Down Expand Up @@ -342,1453 +342,1454 @@
);
};

export const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
const {
workspaceId,
projectName,
workspaceName,
namedWorkspacePath,
runtimeConfig,
onOpenTerminal,
} = props;
const workspaceState = useWorkspaceState(workspaceId);
const chatTranscriptFullWidth = useChatTranscriptFullWidth();
const [transcriptDensity] = useTranscriptDensity();
const { api } = useAPI();
const liveApi = useLiveAPI();
const { workspaceMetadata } = useWorkspaceContext();
const storeRaw = useWorkspaceStoreRaw();
const aggregator = useWorkspaceAggregator(workspaceId);
const workspaceUsage = useWorkspaceUsage(workspaceId);
const reviews = useReviews(workspaceId);
const { autoBackgroundOnSend } = useBackgroundBashActions();
const { clearError: clearBackgroundBashError } = useBackgroundBashError();

// Transcript-only workspaces preserve historical chat and usage after the worktree is deleted,
// so the transcript stays readable while new sends remain disabled.
const meta = workspaceMetadata.get(workspaceId);
const hasRepository = hasWorkspaceRepository(meta);
const transcriptOnly = meta?.transcriptOnly ?? false;
const isPreStreamAgentTask =
Boolean(meta?.parentWorkspaceId) && isBlockedPreStreamTaskStatus(meta?.taskStatus);
const preStreamAgentTaskLabel = meta?.taskStatus === "starting" ? "Starting" : "Queued";
const queuedAgentTaskPrompt =
isPreStreamAgentTask &&
typeof meta?.taskPrompt === "string" &&
meta.taskPrompt.trim().length > 0
? meta.taskPrompt
: null;
const shouldShowQueuedAgentTaskPrompt =
Boolean(queuedAgentTaskPrompt) && (workspaceState?.messages.length ?? 0) === 0;

const { has1MContext } = useProviderOptions();
// Resolve 1M context per-model (uses the pending model for the current workspace)
const pendingSendOptions = useSendMessageOptions(workspaceId);
const pendingModel = pendingSendOptions.model;
const use1M = has1MContext(pendingModel);

const { config: providersConfig } = useProvidersConfig();

// First-paint readiness barrier: all decoration data sources known (or the
// resilience deadline passed). Gates the reveal so decorations can't pop in
// after the transcript is visible.
const chatViewDataReady = useChatViewDataReady(workspaceId);

const { threshold: autoCompactionThreshold, rolloverEnabled } = useAutoCompactionSettings(
workspaceId,
pendingModel
);

const [queuedActionErrorState, setQueuedActionErrorState] = useState<{
workspaceId: string;
messageId: string;
error: string;
} | null>(null);
const queuedActionError =
queuedActionErrorState?.workspaceId === workspaceId &&
queuedActionErrorState.messageId === workspaceState?.queuedMessage?.id
? queuedActionErrorState.error
: null;

const [editingState, setEditingState] = useState(() => ({
workspaceId,
message: undefined as EditingMessageState | undefined,
}));
const editingMessage =
editingState.workspaceId === workspaceId ? editingState.message : undefined;
const setEditingMessage = useCallback(
(message: EditingMessageState | undefined) => {
// Any change of edit target ends the conflict recovery of the previous edit (no-op
// when none is pending); only the composer's own updater keeps a request alive.
storeRaw.cancelTranscriptRefresh(workspaceId);
setEditingState({
workspaceId,
message: transcriptOnly ? undefined : message,
});
},
[storeRaw, workspaceId, transcriptOnly]
);
const updateEditingMessage = (update: (current: EditingMessageState) => EditingMessageState) => {
setEditingState((previous) =>
previous.workspaceId === workspaceId && previous.message
? { ...previous, message: update(previous.message) }
: previous
);
};
// The workspace with an unresolved edit send: no edit starts there meanwhile (#5226).
const [editSendPendingIn, setEditSendPendingIn] = useState<string | null>(null);
const editSendPending = editSendPendingIn === workspaceId;
const handleEditSendPendingChange = (pending: boolean) =>
setEditSendPendingIn((current) =>
pending ? workspaceId : current === workspaceId ? null : current
);
/**
* Enter edit mode with content evidence of the rows the edit deletes. A row the aggregator
* does not hold cannot be fenced (a real state, not a bug): stay out of edit mode and say so.
*/
const beginEditingMessage = (message: EditingMessageState): boolean => {
if (editSendPending) return false;
const precondition = storeRaw.captureHistoryEditPrecondition(workspaceId, message.id);
if (!precondition) {
publishChatError(workspaceId, EDIT_NOT_HELD_MESSAGE);
return false;
}
setEditingMessage({ ...message, precondition });
return true;
};
// Cancelling a compaction edits its request row; the fence is captured before the interrupt,
// so rows the interruption settles inside the range surface as an explicit conflict.
const startEditingMessage = (message: EditingMessageState) => {
beginEditingMessage(message);
};

// Transcript-only workspaces swap the composer for a read-only notice, so clear any
// stale edit state instead of leaving the transcript stuck at an edit cutoff.
useEffect(() => {
if (transcriptOnly && editingMessage) {
setEditingState({ workspaceId, message: undefined });
}
}, [editingMessage, transcriptOnly, workspaceId]);

// A navigation (prompt arrows, ArrowUp edit) targets a row by historyId. The tail-first
// reveal may not have mounted it yet, so the scroll runs from an effect once it is in the
// DOM instead of a one-shot requestAnimationFrame that would find nothing.
const [pendingScrollTarget, setPendingScrollTarget] = useState<{
workspaceId: string;
historyId: string;
} | null>(null);

// Extract state from workspace state

// Keep a ref to the latest workspace state so event handlers (passed to memoized children)
// can stay referentially stable during streaming while still reading fresh data.
const workspaceStateRef = useRef(workspaceState);
useEffect(() => {
workspaceStateRef.current = workspaceState;
}, [workspaceState]);
const {
messages,
canInterrupt,
isCompacting,
isStreamStarting,
loading,
isHydratingTranscript,
isTranscriptCaughtUp,
transcriptReplayFailed,
hasOlderHistory,
loadingOlderHistory,
activeBashMonitorCount,
} = workspaceState;
const shouldShowPinnedTodoList = workspaceState.todos.length > 0;
const shouldShowReviewsBanner = reviews.reviews.length > 0;
const loadOlderMessagesShortcutLabel = formatKeybind(KEYBINDS.LOAD_OLDER_MESSAGES);

const {
warning: contextSwitchWarning,
handleModelChange,
handleCompact: handleContextSwitchCompact,
handleDismiss: handleContextSwitchDismiss,
} = useContextSwitchWarning({
workspaceId,
messages,
pendingModel,
use1M,
workspaceUsage,
api: api ?? undefined,
pendingSendOptions,
providersConfig,
});

// Apply message transformations:
// 1. Merge consecutive identical stream errors
// (bash_output grouping is done at render-time, not as a transformation)
// Use useDeferredValue to allow React to defer the heavy message list rendering
// during rapid updates (streaming), keeping the UI responsive.
// Must be defined before any early returns to satisfy React Hooks rules.
const transformedMessages = useMemo(() => mergeConsecutiveStreamErrors(messages), [messages]);
const immediateMessageSnapshot = useMemo(
() => ({ workspaceId, messages: transformedMessages }),
[workspaceId, transformedMessages]
);
const deferredMessageSnapshot = useDeferredValue(immediateMessageSnapshot);

// CRITICAL: Show immediate messages when streaming or when message count changes.
// useDeferredValue can defer indefinitely if React keeps getting new work (rapid deltas).
// During active streaming (reasoning, text), we MUST show immediate updates or the UI
// appears frozen while only the token counter updates (reads aggregator directly).
// Also bypass the deferred snapshot when it still belongs to the previous workspace so
// chat switches cannot briefly render stale transcript rows from the old workspace.
const shouldBypassDeferral = shouldBypassDeferredMessages(
immediateMessageSnapshot.messages,
deferredMessageSnapshot.messages,
{
immediateWorkspaceId: workspaceId,
deferredWorkspaceId: deferredMessageSnapshot.workspaceId,
}
);
const deferredMessages = shouldBypassDeferral
? immediateMessageSnapshot.messages
: deferredMessageSnapshot.messages;

const latestMessageId = getLastNonDecorativeMessage(deferredMessages)?.id ?? null;
const messageListContextValue = useMemo(
() => ({
workspaceId,
latestMessageId,
openTerminal: onOpenTerminal,
}),
[workspaceId, latestMessageId, onOpenTerminal]
);

const transcriptRowDerivations = useTranscriptRowDerivations({
workspaceId,
messages: deferredMessages,
});
const { bashOutputGroupInfos, expandedBashGroups, expandBashGroup } = transcriptRowDerivations;

const transcriptBundles = useTranscriptBundles({
workspaceId,
messages: deferredMessages,
transcriptDensity,
isTurnActive: isStreamStarting || canInterrupt,
});
const {
workBundleInfos,
operationalBundleInfos,
workBundleExpansionOverrides,
operationalBundleExpansionOverrides,
setWorkBundleExpanded,
setOperationalBundleExpanded,
} = transcriptBundles;

// Tail-first rendering: projections above are computed over the full array; only the
// mounted range starts at `revealFromIndex`. A cut is safe when the row is not inside a
// bundle or group (a head counts as safe), so bundles always mount whole.
const isSafeRevealCut = (index: number): boolean => {
const bashOutputGroup = bashOutputGroupInfos[index];
if (bashOutputGroup !== undefined && bashOutputGroup.position !== "first") return false;
const workBundle = workBundleInfos?.[index];
if (workBundle !== undefined && workBundle.position !== "head") return false;
const operationalBundle = operationalBundleInfos?.[index];
return operationalBundle === undefined || operationalBundle.position === "head";
};
// Keep rendering cached transcript rows during incremental (since) catch-up so workspace
// switches feel stable, even when they are known to be missing backend content: the server
// verified every row up to the cursor, and rows after it are swapped for the server's
// copies at caught-up, so the missing content mostly appends. Stale rows
// under a full replay hide behind the skeleton instead. The stream/monitor barrier
// lives in the composer dock, so it never vetoes the skeleton. The skeleton
// additionally holds until decoration data sources are known so the transcript and all
// composer decorations reveal in ONE commit — see useChatViewDataReady for the contract.
const { showHydrationPlaceholder: showTranscriptHydrationPlaceholder, revealDecorations } =
computeChatViewReveal({
isHydratingTranscript,
chatViewDataReady,
hasRenderableMessages: deferredMessages.length > 0,
isTranscriptStale: workspaceState.isTranscriptStale,
isIncrementalCatchUp: workspaceState.isIncrementalCatchUp,
});
// While the skeleton owns the pane no row is mounted, so the reveal must not advance behind
// it: it would otherwise mount the whole transcript in the one commit that replaces the
// skeleton. Handing it no rows keeps it idle; the real transcript then starts tail-first.
const {
fromIndex: revealFromIndex,
isFullyRevealed,
isRevealPaused,
revealMore,
revealThrough,
} = useBoundedTranscriptReveal({
workspaceId,
messages: showTranscriptHydrationPlaceholder ? EMPTY_TRANSCRIPT : deferredMessages,
isSafeCut: isSafeRevealCut,
rowWeight: (index) => estimateTranscriptRowWeight(deferredMessages[index]),
});
const revealedMessages =
revealFromIndex === 0 ? deferredMessages : deferredMessages.slice(revealFromIndex);
const hasCommittedTranscriptRow =
!showTranscriptHydrationPlaceholder && revealedMessages.length > 0;
// Chat-switch User Timing (#4504): first transcript row painted after a switch. The commit
// before the store activates the workspace can hold cached rows that a synchronous re-render
// replaces with the skeleton before paint; the next-frame check drops those.
useEffect(() => {
if (!hasCommittedTranscriptRow) return;
return markChatSwitchMilestoneOnNextFrame(workspaceId, "first-row");
}, [workspaceId, hasCommittedTranscriptRow]);
// Settled: every row is mounted, or the automatic reveal paused at its rows budget (#4869;
// older rows then mount only on request) — either way no chunk is still mounting.
const revealSettled = isFullyRevealed || isRevealPaused;
// Streaming rows render synchronously while older chunks still mount (see
// TranscriptBackfillContext). Gated on an active stream so a switch to an idle chat never
// flips the value, which would re-render every mounted markdown row once the reveal ends.
const isTranscriptBackfillingDuringStream = canInterrupt && !revealSettled;
// A paused reveal offers its unmounted rows first. Older server pages prepend above rows the
// reveal has not reached yet; offer them once it has.
const shouldRenderLoadOlderMessagesButton =
(isRevealPaused || (hasOlderHistory && isFullyRevealed)) && !isPixelSnapshotEnvironment();
// The server-page loading state never applies to mounting already-loaded rows.
const isLoadingOlderHistoryPage = loadingOlderHistory && !isRevealPaused;

// Rollover mode evaluates the clamped threshold, so the chat-input bar's visibility and
// text must use the same effective value the slider label advertises.
const autoCompactionResult = useMemo(() => {
const effectiveAutoCompactionThreshold = getEffectiveThreshold({
threshold: autoCompactionThreshold,
rolloverEnabled,
modelContextLimit: pendingModel
? getEffectiveContextLimit(pendingModel, use1M, providersConfig)
: null,
});
return checkAutoCompaction(
workspaceUsage,
pendingModel,
use1M,
effectiveAutoCompactionThreshold / 100,
undefined,
providersConfig
);
}, [
workspaceUsage,
pendingModel,
use1M,
providersConfig,
autoCompactionThreshold,
rolloverEnabled,
]);

// Show warning when: shouldShowWarning flag is true AND not currently compacting.
// Context-switch warning takes priority so we don't show competing banners.
const shouldShowCompactionWarning =
!isCompacting && autoCompactionResult.shouldShowWarning && !contextSwitchWarning;

// Vim mode state - needed for keybind selection (Ctrl+C in vim, Esc otherwise)
const vimEnabled = useUserPreferences(
(preferences) => preferences.appearance?.vimEnabled === true
);

// Use auto-scroll hook for scroll management
const {
contentRef,
sentinelRef,
autoScroll,
disableAutoScroll,
jumpToBottom,
handleScroll,
markUserScrollIntent,
handleScrollContainerWheel,
handleScrollContainerMouseDown,
handleScrollContainerMouseMove,
handleScrollContainerMouseUp,
handleScrollContainerKeyDown,
} = useAutoScroll();

const [pendingTimelineReveal, setPendingTimelineReveal] = useState<
CustomEventPayloads[typeof CUSTOM_EVENTS.REVEAL_TIMELINE_ANCHOR] | null
>(null);
const highlightedTimelineElementRef = useRef<HTMLElement | null>(null);

useEffect(() => {
const handleTimelineReveal = (event: Event) => {
const customEvent = event as CustomEventType<typeof CUSTOM_EVENTS.REVEAL_TIMELINE_ANCHOR>;
if (customEvent.detail.workspaceId !== workspaceId) {
return;
}
disableAutoScroll();
setPendingTimelineReveal(customEvent.detail);
};

window.addEventListener(CUSTOM_EVENTS.REVEAL_TIMELINE_ANCHOR, handleTimelineReveal);
return () => {
window.removeEventListener(CUSTOM_EVENTS.REVEAL_TIMELINE_ANCHOR, handleTimelineReveal);
};
}, [disableAutoScroll, workspaceId]);

// Store history loads can finish before the deferred transcript renders.
// Keep the reveal pending while its collapsed bundles expand.
useLayoutEffect(() => {
if (!pendingTimelineReveal) {
return;
}
// Like pendingScrollTarget: the tail being pinned again supersedes a reveal still waiting.
if (pendingTimelineReveal.workspaceId !== workspaceId || autoScroll) {
setPendingTimelineReveal(null);
return;
}

const targetIndex = findTimelineRevealMessageIndex(deferredMessages, pendingTimelineReveal);
if (targetIndex < 0) {
return;
}

const bashOutputGroup = bashOutputGroupInfos[targetIndex];
const bashGroupKey = bashOutputGroup
? deferredMessages[bashOutputGroup.firstIndex]?.id
: undefined;
if (bashGroupKey && !expandedBashGroups.has(bashGroupKey)) {
expandBashGroup(bashGroupKey);
return;
}

const workBundle = workBundleInfos?.[targetIndex];
if (workBundle && workBundleExpansionOverrides.get(workBundle.key) !== true) {
setWorkBundleExpanded(workBundle.key, true);
return;
}

const operationalBundle = operationalBundleInfos?.[targetIndex];
if (
operationalBundle &&
operationalBundleExpansionOverrides.get(operationalBundle.key) !== true
) {
setOperationalBundleExpanded(operationalBundle.key, true);
return;
}

// The tail-first reveal mounts older rows in chunks; re-run once the target's chunk lands.
// The automatic reveal may have paused above the target, so ask it to reach the target.
if (targetIndex < revealFromIndex) {
revealThrough(targetIndex);
return;
}

const scrollContainer = contentRef.current;
const targetElement = scrollContainer
? findTranscriptRevealElement(scrollContainer, pendingTimelineReveal)
: undefined;
if (!targetElement) {
return;
}

// The highlight marks where the reveal landed and stays until the next reveal replaces it or the
// pane unmounts. Dismissing it on a timer would coordinate DOM state outside React and can
// overrun whenever a backgrounded tab throttles timers.
highlightedTimelineElementRef.current?.classList.remove(TIMELINE_REVEAL_HIGHLIGHT_CLASS);
highlightedTimelineElementRef.current = targetElement;
targetElement.classList.add(TIMELINE_REVEAL_HIGHLIGHT_CLASS);
targetElement.scrollIntoView({ behavior: "smooth", block: "center" });
setPendingTimelineReveal(null);
}, [
autoScroll,
bashOutputGroupInfos,
contentRef,
deferredMessages,
expandBashGroup,
expandedBashGroups,
operationalBundleExpansionOverrides,
operationalBundleInfos,
pendingTimelineReveal,
revealFromIndex,
revealThrough,
setOperationalBundleExpanded,
setWorkBundleExpanded,
workBundleExpansionOverrides,
workBundleInfos,
workspaceId,
]);

useEffect(() => {
return () => {
highlightedTimelineElementRef.current?.classList.remove(TIMELINE_REVEAL_HIGHLIGHT_CLASS);
highlightedTimelineElementRef.current = null;
};
}, [workspaceId]);

// The composer dock lives inside the scrollport (sticky to its bottom), so
// mousedown/keydown events from the composer bubble to the transcript
// handlers. Typing or clicking in the composer is not transcript scroll
// intent. Wheel/touch are intentionally not filtered because native scroll
// chaining can move the transcript.
const composerDockRef = useRef<HTMLDivElement>(null);
const isComposerDockEvent = useCallback((target: EventTarget | null): boolean => {
return target instanceof Node && (composerDockRef.current?.contains(target) ?? false);
}, []);

const handleTranscriptWheel = handleScrollContainerWheel;

const handleTranscriptMouseDown = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (isComposerDockEvent(event.target)) {
return;
}
handleScrollContainerMouseDown(event);
},
[handleScrollContainerMouseDown, isComposerDockEvent]
);

const handleTranscriptTouchMove = markUserScrollIntent;

const handleTranscriptKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (isComposerDockEvent(event.target)) {
return;
}
handleScrollContainerKeyDown(event);
},
[handleScrollContainerKeyDown, isComposerDockEvent]
);

const handleJumpToBottom = jumpToBottom;

// Handler to navigate (scroll) to a specific message by historyId
const handleNavigateToMessage = useCallback(
(historyId: string) => {
// Disable auto-scroll so the navigation isn't undone by streaming content
disableAutoScroll();
setPendingScrollTarget({ workspaceId, historyId });
},
[disableAutoScroll, workspaceId]
);

useEffect(() => {
if (pendingScrollTarget === null) return;
// Navigation disables auto-scroll first; the tail being pinned again (jump to bottom, a
// send, dismissing an edit, scrolling back down) supersedes a navigation still waiting
// for its row to mount, so the chunk mounting later cannot scroll away from the tail.
if (pendingScrollTarget.workspaceId !== workspaceId || autoScroll) {
setPendingScrollTarget(null);
return;
}
const scrollContainer = contentRef.current;
const target = scrollContainer
? findTranscriptMessageElement(scrollContainer, pendingScrollTarget.historyId)
: undefined;
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" });
setPendingScrollTarget(null);
return;
}
// Not in the DOM: keep waiting only while the row exists below the reveal boundary, and
// ask the reveal to reach it (the automatic reveal may have paused above it). A row that
// is gone, or eligible but hidden inside a collapsed bundle, is dropped (the pre-reveal
// behavior for an unmounted target was a silent no-op too).
const targetIndex = deferredMessages.findIndex(
(message) => "historyId" in message && message.historyId === pendingScrollTarget.historyId
);
if (targetIndex === -1 || targetIndex >= revealFromIndex) {
setPendingScrollTarget(null);
return;
}
revealThrough(targetIndex);
}, [
autoScroll,
contentRef,
deferredMessages,
pendingScrollTarget,
revealFromIndex,
revealThrough,
workspaceId,
]);

const userMessageNavigationByHistoryId = useUserMessageNavigation({
messages: deferredMessages,
onNavigateToMessage: handleNavigateToMessage,
});

// ChatInput API for focus management
const chatInputAPI = useRef<ChatInputAPI | null>(null);

const handleQuoteText = useCallback((quotedText: string) => {
chatInputAPI.current?.appendText(quotedText);
chatInputAPI.current?.focus();
}, []);

// Right-clicking transcript text offers quick quote/copy actions,
// using selection first and hovered text as a fallback when nothing is selected.
const transcriptContextMenu = useTranscriptContextMenu({
transcriptRootRef: contentRef,
onQuoteText: handleQuoteText,
hasInputTarget: !transcriptOnly,
});

// Workspace switches should not leak background bash errors into the newly selected chat.
useEffect(() => {
clearBackgroundBashError();
}, [clearBackgroundBashError, workspaceId]);

useEffect(() => {
setEditingState({ workspaceId, message: undefined });
setPendingTimelineReveal(null);
}, [workspaceId]);

const handleChatInputReady = (api: ChatInputAPI) => {
chatInputAPI.current = api;
props.onChatInputReady?.(api);
};

// Handler for review notes from Code Review tab - adds review (starts attached)
// Depend only on addReview (not whole reviews object) to keep callback stable
const { addReview, checkReview } = reviews;

const handleCheckReviews = useCallback(
(ids: string[]) => {
for (const id of ids) {
checkReview(id);
}
// These notes were just sent: persist them as checked now rather than after the store's
// flush debounce, so a crash right after the send cannot bring them back as attached.
getReviewStateStore()
.flush(workspaceId)
.catch((error: unknown) => {
console.warn("Failed to persist sent review notes; the store keeps retrying:", error);
});
},
[checkReview, workspaceId]
);
const handleReviewNote = useCallback(
(data: ReviewNoteData) => {
addReview(data);
// New reviews start with status "attached" so they appear in chat input immediately
},
[addReview]
);

// Handlers for editing messages
const handleEditUserMessage = (message: EditingMessageState) => {
// Rows hide their Edit affordance while hydrating; this covers a click racing catch-up
// being lost (workspace switch, reconnect) so the composer never enters edit mode
// against a transcript that is not a verified copy of history.
if (!isTranscriptMutationAllowed(workspaceId)) return;
beginEditingMessage(message);
};

const restoreQueuedDraft = useCallback(
async (queuedMessage: QueuedMessageData) => {
const inputApi = chatInputAPI.current;
if (!inputApi) return;

await api?.workspace.clearQueue({ workspaceId });
inputApi.restoreDraft(normalizeQueuedMessage(queuedMessage), {
retainedSendIds: queuedMessage.sendIds,
});
},
[api, workspaceId]
);

const handleEditQueuedMessage = async () => {
const queuedMessage = workspaceState?.queuedMessage;
if (!queuedMessage) return;

await restoreQueuedDraft(queuedMessage);
};

const sendQueuedImmediatelyInFlightRef = useRef<string | null>(null);

// The backend can resolve the interrupt RPC before the queued-message-cleared
// event renders, so keep duplicate send-now attempts blocked until the queued
// message id changes or clears.
useEffect(() => {
const queuedMessageId = workspaceState?.queuedMessage?.id ?? null;
if (queuedMessageId !== sendQueuedImmediatelyInFlightRef.current) {
sendQueuedImmediatelyInFlightRef.current = null;
}
}, [workspaceState?.queuedMessage?.id]);

const clearQueuedActionError = () => setQueuedActionErrorState(null);
const handleQueuedActionError = (error: unknown) => {
const queuedMessageId = workspaceState?.queuedMessage?.id;
if (!queuedMessageId) return;
setQueuedActionErrorState({
workspaceId,
messageId: queuedMessageId,
error: error instanceof Error ? error.message : String(error),
});
};

// Handler for sending queued message immediately (interrupt + send)
const handleSendQueuedImmediately = async () => {
const queuedMessage = workspaceState?.queuedMessage;
if (
!api ||
!queuedMessage ||
!workspaceState.canInterrupt ||
sendQueuedImmediatelyInFlightRef.current === queuedMessage.id
) {
return;
}

clearQueuedActionError();
sendQueuedImmediatelyInFlightRef.current = queuedMessage.id;
// Release the duplicate-send guard only if it still points at this attempt; a
// newer queued message (or a clear) may have already reset it in the meantime.
const clearInFlightGuardIfCurrent = () => {
if (sendQueuedImmediatelyInFlightRef.current === queuedMessage.id) {
sendQueuedImmediatelyInFlightRef.current = null;
}
};
const interruptResult = await runWithCatch(
() => {
// Set "interrupting" state immediately so UI shows "interrupting..." without flash.
storeRaw.setInterrupting(workspaceId);
return api.workspace.interruptStream({
workspaceId,
options: { sendQueuedImmediately: true },
});
},
(error) => {
clearInFlightGuardIfCurrent();
throw error;
}
);
if (!interruptResult.success) {
clearInFlightGuardIfCurrent();
throw new Error(interruptResult.error);
}
};

const handleQueuedDispatchModeChange = async (queueDispatchMode: QueueDispatchMode) => {
clearQueuedActionError();
if (!api) {
if (!liveApi) {
throw new Error("Workspace API is unavailable.");
}
const result = await api.workspace.setQueuedMessageDispatchMode({
const result = await liveApi.workspace.setQueuedMessageDispatchMode({
workspaceId,
queueDispatchMode,
});
if (!result.success) {
throw new Error(result.error);
}
if (!result.data) {
throw new Error("The queued message is no longer available.");
}
};

const handleCancelCompactionFromBarrier = () => {
if (!api || !aggregator) {
return;
}

void cancelCompaction(api, workspaceId, aggregator, startEditingMessage);
};

const handleEditLastUserMessage = async () => {
if (transcriptOnly) return;

const current = workspaceStateRef.current;
if (!current) return;

if (current.queuedMessage) {
// A queued artifact send is the artifact's message, not a draft (its card has no Edit):
// restoring it would cancel the send and put its model-facing payload in the composer.
if (current.queuedMessage.artifactInteraction != null) return;
await restoreQueuedDraft(current.queuedMessage);
return;
}

// Otherwise, edit last user message. `current.messages` may be a cached/provisional view
// while the replay is still running, so ArrowUp must not enter edit mode until caught up.
if (!isTranscriptMutationAllowed(workspaceId)) return;
const transformedMessages = mergeConsecutiveStreamErrors(current.messages);
const lastUserMessage = [...transformedMessages]
.reverse()
.find(
(msg): msg is Extract<DisplayedMessage, { type: "user" }> =>
msg.type === "user" && canEditDisplayedUserMessage(msg)
);

if (!lastUserMessage) {
return;
}

if (!beginEditingMessage(buildEditingStateFromDisplayed(lastUserMessage))) return;
disableAutoScroll(); // Show jump-to-bottom indicator

// Scroll to the message being edited once it is mounted (see pendingScrollTarget).
setPendingScrollTarget({ workspaceId, historyId: lastUserMessage.historyId });
};

const handleEditLastUserMessageClick = () => {
void handleEditLastUserMessage();
};

const handleCancelEdit = useCallback(() => {
setEditingMessage(undefined);
// handleEditLastUserMessage disabled auto-scroll to keep the view centered
// on the edited message. Dismissing the edit hands scroll ownership back to
// the transcript tail; without this the view stays scrolled up until the
// user manually returns to the bottom.
handleJumpToBottom();
}, [handleJumpToBottom, setEditingMessage]);

const handleMessageSendStarted = useCallback(() => {
// Re-arm and pin before the send request crosses the IPC boundary. Waiting for
// send success can be too late because the backend may not resolve until the
// stream has already produced rows, leaving the first deltas offscreen when the
// user had previously scrolled up.
handleJumpToBottom();
}, [handleJumpToBottom]);

const handleMessageSent = useCallback(
(dispatchMode: QueueDispatchMode = "tool-end") => {
// Only background foreground bashes for "tool-end" sends (Enter).
// "turn-end" sends (Ctrl/Cmd+Enter) let the stream finish naturally —
// backgrounding would disrupt a foreground bash the user wants to complete.
if (dispatchMode === "tool-end") {
autoBackgroundOnSend();
}

// Slash-command send paths still report after backend success; keep this
// harmless duplicate pin so those paths also re-arm auto-scroll.
handleJumpToBottom();
},
[autoBackgroundOnSend, handleJumpToBottom]
);

const handleClearHistory = useCallback(
async (percentage = 1.0) => {
// Clearing acts on the transcript the user sees. The only caller is the /clear slash
// command, whose phase already turns thrown errors into a restore + error toast.
if (!isTranscriptMutationAllowed(workspaceId)) {
throw new Error(TRANSCRIPT_NOT_CAUGHT_UP_MESSAGE);
}
// Re-arm the tail before clearing so the empty/starting state owns the bottom.
handleJumpToBottom();

// Truncate history in backend
const result = await api?.workspace.truncateHistory({ workspaceId, percentage });
// A partial failure (history already deleted but durable cleanup —
// e.g. sandbox kernel invalidation — failed) carries the only warning
// that cleared state may reappear after a restart. Throw so callers
// (slash command, dialogs) surface it instead of reporting success.
if (result && !result.success) {
throw new Error(result.error);
}
},
[workspaceId, handleJumpToBottom, api]
);

const handleResetContext = useCallback(async (): Promise<"reset" | "noop"> => {
// Same contract as handleClearHistory: the /reset (soft clear) phase surfaces the throw.
if (!isTranscriptMutationAllowed(workspaceId)) {
throw new Error(TRANSCRIPT_NOT_CAUGHT_UP_MESSAGE);
}
handleJumpToBottom();

const result = await api?.workspace.resetContext({ workspaceId });
if (!result?.success) {
throw new Error(result?.error ?? "Failed to reset context");
}
return result.data;
}, [workspaceId, handleJumpToBottom, api]);

const openInEditor = useOpenInEditor();
const handleOpenInEditor = useCallback(() => {
void openInEditor(workspaceId, namedWorkspacePath, runtimeConfig);
}, [workspaceId, namedWorkspacePath, openInEditor, runtimeConfig]);

// Intentionally no message/todo-driven auto-scroll effect here. Bottom pinning is
// owned by the scrollport/content ResizeObservers inside `useAutoScroll`, which
// pins viewport or content-size changes before paint. Calling `performAutoScroll`
// as a separate double-RAF on every delta used to race the RO pin, occasionally
// painting one frame at the wrong scrollTop (visible as a brief downward jitter).

const hasLoadedTranscriptRows = !workspaceState.loading && workspaceState.messages.length > 0;

// Reset transcript scroll ownership when switching workspaces. `jumpToBottom` both re-arms
// the ref-backed auto-scroll flag and pins any cached rows before paint; if rows are still
// hydrating, the next content resize owns the tail instead of showing the prior workspace's state.
useLayoutEffect(() => {
handleJumpToBottom();
}, [hasLoadedTranscriptRows, handleJumpToBottom, workspaceId]);

const shouldShowStreamingBarrier = isStreamStarting || canInterrupt;
// An armed background bash monitor means the turn ended but the agent will be
// woken on matching output. Keep the barrier mounted so StreamingBarrier can
// show its "waiting on monitor" state instead of the chat looking idle.
const shouldMountStreamingBarrier = shouldShowStreamingBarrier || activeBashMonitorCount > 0;
const showEmptyTranscriptPlaceholder =
deferredMessages.length === 0 &&
!showTranscriptHydrationPlaceholder &&
!shouldMountStreamingBarrier;
// Compute retry/interrupted chrome once for both keybinds and UI. Interruption and the retry
// candidate come from the latest rows; dividers are placed on the rendered (deferred) rows.
const {
lastRetryCandidateMessage,
shouldMountRetryBarrier,
showRetryBarrierUI,
interruptedBarrierMessageIds,
interruptedTailResumable,
} = getRetryBarrierDerivation({
messages: workspaceState.messages,
renderedMessages: deferredMessages,
pendingStreamStartTime: workspaceState.pendingStreamStartTime,
runtimeStatus: workspaceState.runtimeStatus,
lastAbortReason: workspaceState.lastAbortReason,
autoRetryStatus: workspaceState.autoRetryStatus,
isHydratingTranscript,
isTurnActive: shouldShowStreamingBarrier,
transcriptOnly,
});

// Owned here so the click and keybind paths share one resume/error. resetKey is
// the resume target, so error/spinner reset when the interrupted turn changes.
const { resume: resumeInterruptedStreamAsync, error: resumeInterruptedError } = useResumeStream(
workspaceId,
lastRetryCandidateMessage?.id,
workspaceState.messages
);
const resumeInterruptedStream = () => void resumeInterruptedStreamAsync();
// Live status belongs beside the input, below async decorations: neither history
// reveal nor banners arriving should bump the label and Stop across the screen.
const turnStatus = shouldMountStreamingBarrier ? (
<ChatDockSurface>
<StreamingBarrier
workspaceId={workspaceId}
vimEnabled={vimEnabled}
onCancelCompaction={handleCancelCompactionFromBarrier}
/>
</ChatDockSurface>
) : null;
const transcriptTailItems: TranscriptTailStackItem[] = [];
if (shouldMountRetryBarrier) {
transcriptTailItems.push(
createTranscriptTailStackItem({
key: "retry-barrier",
node: <RetryBarrier workspaceId={workspaceId} visible={showRetryBarrierUI} />,
})
);
}
if (shouldShowQueuedAgentTaskPrompt) {
transcriptTailItems.push(
createTranscriptTailStackItem({
key: "queued-agent-prompt",
node: (
<div className="mt-4 mb-1 ml-auto w-fit max-w-full">
<div className="rounded-lg border border-[var(--color-user-border)] bg-[var(--color-user-surface)] px-3 py-2 text-sm">
<div className="text-muted mb-1 text-[11px] font-medium">
{preStreamAgentTaskLabel}
</div>
<MarkdownRenderer
content={queuedAgentTaskPrompt ?? ""}
className="user-message-markdown text-foreground"
preserveLineBreaks
style={{ overflowWrap: "break-word", wordBreak: "break-word" }}
/>
</div>
</div>
),
})
);
}
// Rows a paused reveal mounts on request land above the reading position (#4869). Native
// scroll anchoring keeps that position only while scrollTop > 0, and the Load-older button
// sits at scrollTop 0, so the first mounted row's viewport offset is pinned explicitly from
// the request until the reveal settles again. Offsets are viewport-relative and measured after
// layout, so a native anchoring adjustment is never applied twice.
const loadOlderAnchorRef = useRef<{
element: HTMLElement;
top: number;
/** scrollTop after the last correction; a scroll away from it moves `top` (see below). */
scrollTop: number;
} | null>(null);
useLayoutEffect(() => {
const anchor = loadOlderAnchorRef.current;
const scrollContainer = contentRef.current;
if (anchor === null || scrollContainer === null) return;
if (anchor.element.isConnected) {
const delta = anchor.element.getBoundingClientRect().top - anchor.top;
if (delta !== 0) scrollContainer.scrollTop += delta;
anchor.scrollTop = scrollContainer.scrollTop;
}
if (revealSettled || !anchor.element.isConnected) loadOlderAnchorRef.current = null;
}, [contentRef, revealFromIndex, revealSettled, shouldRenderLoadOlderMessagesButton]);
const handleTranscriptScroll = (event: React.UIEvent<HTMLDivElement>) => {
handleScroll(event);
// The reader scrolling while the requested rows mount moves the position to keep, so the
// next correction never undoes it. Shifted by the scroll distance rather than re-measured,
// so a correction's sub-pixel rounding never accumulates across chunks.
const anchor = loadOlderAnchorRef.current;
if (anchor !== null) {
const scrollTop = event.currentTarget.scrollTop;
anchor.top -= scrollTop - anchor.scrollTop;
anchor.scrollTop = scrollTop;
}
};

const handleLoadOlderHistory = useCallback(() => {
if (!shouldRenderLoadOlderMessagesButton) {
return;
}
// Already-loaded rows above a paused reveal mount before any older server page is fetched.
// Requests made while those rows mount are ignored (the button is gone until the reveal
// pauses again), like presses while a server page loads.
if (isRevealPaused) {
// While locked to the bottom the sentinel keeps the tail in place instead.
const firstRow = autoScroll
? null
: contentRef.current?.querySelector<HTMLElement>("[data-message-id]");
loadOlderAnchorRef.current =
firstRow && contentRef.current
? {
element: firstRow,
top: firstRow.getBoundingClientRect().top,
scrollTop: contentRef.current.scrollTop,
}
: null;
revealMore();
return;
}
if (loadingOlderHistory) {
return;
}

storeRaw.loadOlderHistory(workspaceId).catch((error) => {
console.warn(`[ChatPane] Failed to load older history for ${workspaceId}:`, error);
});
}, [
autoScroll,
contentRef,
isRevealPaused,
loadingOlderHistory,
revealMore,
shouldRenderLoadOlderMessagesButton,
storeRaw,
workspaceId,
]);

// Handle keyboard shortcuts (using optional refs that are safe even if not initialized)
useAIViewKeybinds({
workspaceId,
// Allow interrupt keybind even while waiting for stream-start ("starting...").
canInterrupt:
(workspaceState?.canInterrupt ?? false) || (workspaceState?.isStreamStarting ?? false),
// The visible barrier, not the raw interruption: a context_exceeded error hides the barrier,
// and Esc must not send a Stop (persisting an auto-retry opt-out) for nothing visible (#5111).
showRetryBarrier: showRetryBarrierUI,
chatInputAPI,
jumpToBottom: handleJumpToBottom,
loadOlderHistory: shouldRenderLoadOlderMessagesButton ? handleLoadOlderHistory : null,
handleOpenTerminal: onOpenTerminal,
handleOpenInEditor,
aggregator,
setEditingMessage: startEditingMessage,
vimEnabled,
canResumeInterruptedStream: interruptedTailResumable,
resumeInterruptedStream,
});

// Clear editing state if the message being edited no longer exists
// Must be before early return to satisfy React Hooks rules
useEffect(() => {
if (!workspaceState || !editingMessage) return;
// Conflict recovery re-reads the transcript (a full replay empties the aggregator first,
// a pre-window range discards cached pages); the refresh outcome decides whether the
// edited row is gone, not the transient absence of its row.
if (editingMessage.preconditionInvalidated === true) return;

const transformedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
const editCutoffHistoryId = transformedMessages.find(
(
msg
): msg is Exclude<
DisplayedMessage,
{ type: "history-hidden" | "workspace-init" | "compaction-boundary" }
> =>
msg.type !== "history-hidden" &&
msg.type !== "workspace-init" &&
msg.type !== "compaction-boundary" &&
msg.historyId === editingMessage.id
)?.historyId;

if (!editCutoffHistoryId) {
// Message was replaced or deleted - clear editing state
setEditingMessage(undefined);
}
}, [workspaceState, editingMessage, setEditingMessage]);

// When editing, find the cutoff point
const editCutoffHistoryId = editingMessage
? transformedMessages.find(
(
msg
): msg is Exclude<
DisplayedMessage,
{ type: "history-hidden" | "workspace-init" | "compaction-boundary" }
> =>
msg.type !== "history-hidden" &&
msg.type !== "workspace-init" &&
msg.type !== "compaction-boundary" &&
msg.historyId === editingMessage.id
)?.historyId
: undefined;

// Find the ID of the latest propose_plan tool call for external edit detection
// Only the latest plan should fetch fresh content from disk
let latestProposePlanId: string | null = null;
for (let i = transformedMessages.length - 1; i >= 0; i--) {
const msg = transformedMessages[i];
if (msg.type === "tool" && msg.toolName === "propose_plan") {
latestProposePlanId = msg.id;
break;
}
}

const renderMessageAtIndex = (
message: DisplayedMessage,
index: number,
options: { key: string; className?: string }
): React.ReactNode => {
const rowProps = getTranscriptRowProps({
derivations: transcriptRowDerivations,
userMessageNavigationByHistoryId,
messages: deferredMessages,
message,
index,
});
const { bashOutputGroup, bashGroupKey } = rowProps;

if (rowProps.hidden) {
return null;
}

const isAtCutoff =
editCutoffHistoryId !== undefined &&
message.type !== "history-hidden" &&
message.type !== "workspace-init" &&
message.type !== "compaction-boundary" &&
message.historyId === editCutoffHistoryId;

const messageNode = (
<MessageRenderer
message={message}
// No onEdit → UserMessage omits its Edit button; provisional (not caught-up) rows
// must not offer an edit whose truncation point the backend may not agree with.
onEditUserMessage={
transcriptOnly || !isTranscriptCaughtUp ? undefined : handleEditUserMessage
}
workspaceId={workspaceId}
isCompacting={isCompacting}
editSendPending={editSendPending}
onReviewNote={handleReviewNote}
isLatestProposePlan={
message.type === "tool" &&
message.toolName === "propose_plan" &&
message.id === latestProposePlanId
}
bashOutputGroup={bashOutputGroup}
taskReportLinking={rowProps.taskReportLinking}
userMessageNavigation={rowProps.userMessageNavigation}
/>
);

return (
<React.Fragment key={options.key}>
{options.className ? <div className={options.className}>{messageNode}</div> : messageNode}
{bashOutputGroup?.position === "first" && bashGroupKey && (
<BashOutputCollapsedIndicator
processId={bashOutputGroup.processId}
collapsedCount={bashOutputGroup.collapsedCount}
isExpanded={rowProps.isBashGroupExpanded}
onToggle={() => transcriptRowDerivations.toggleBashOutputGroup(bashGroupKey)}
/>
)}
{isAtCutoff && <EditCutoffBarrier />}
{interruptedBarrierMessageIds.has(message.id) && (
<InterruptedBarrier
resumable={interruptedTailResumable && message.id === lastRetryCandidateMessage?.id}
onResume={resumeInterruptedStream}
error={resumeInterruptedError}
/>
)}
</React.Fragment>
);
};

return (
<>
<PerfRenderMarker id="chat-pane.transcript">
{/* Spacer for fixed mobile header - mobile-header-spacer adds padding-top on touch devices.
The composer dock is IN-FLOW scroll content (sticky to the scrollport
bottom), so this region — and therefore the scrollport's clientHeight —
never resizes when the composer grows or shrinks. */}
<div
className={cn(
"relative flex-1 overflow-hidden",
props.embedded !== true && "mobile-header-spacer"
)}
>
<div
ref={contentRef}
onWheel={handleTranscriptWheel}
onMouseDown={handleTranscriptMouseDown}
onMouseMove={handleScrollContainerMouseMove}
onMouseUp={handleScrollContainerMouseUp}
onTouchMove={handleTranscriptTouchMove}
onKeyDown={handleTranscriptKeyDown}
onScroll={handleTranscriptScroll}
onContextMenu={transcriptContextMenu.onContextMenu}
tabIndex={0}
data-testid="message-window"
// Settled marker for perf tests and story play helpers: includes
// decoration data readiness AND the tail-first reveal having settled
// (fully revealed or paused at the automatic budget), so waiting on it
// observes the chat view's final layout rather than a tail whose
// earlier chunks are still committing.
data-loaded={!loading && !isHydratingTranscript && chatViewDataReady && revealSettled}
// Browser scroll anchoring stays ENABLED on the scrollport; the
// overflow-anchor policy lives on the inner content (opt rows out while
// locked so the bottom sentinel is the sole anchor). No bottom padding:
// clearance for the composer is the in-flow dock itself, so it is always
// exact and reserved in the same layout pass as any dock height change.
// The flex column makes the transcript content stretch (flex-1) so the
// dock sits at the scrollport bottom even when the transcript is short.
// The named `transcript` container is what the sticky plan TOC queries
// for visibility — using a container query rather than a viewport media
// query means sidebars opening/closing correctly hide the TOC even when
// the viewport width is unchanged. See `.plan-toc-aside` in globals.css.
// `--chat-reserved-inline-end`: room a layout floats over the chat's right edge (the
// collapsed right sidebar's overview card, see globals.css). Padding by it centers the
// column, and the composer under it, in the space left of the overlay, so the column
// sits as far from the overlay as from the left edge. The transcript container query
// measures the content box, so the plan TOC sees the narrower gutters too.
className="@container/transcript flex h-full flex-col overflow-x-hidden overflow-y-auto pt-[15px] pr-[calc(15px+var(--chat-reserved-inline-end,0px))] pl-[15px] leading-[1.5] break-words whitespace-pre-wrap"
>
<div
// While locked, opt the whole transcript subtree out of scroll
// anchoring so the only anchor candidate is the sibling bottom
// sentinel below — native anchoring then pins the bottom on append.
style={autoScroll ? TRANSCRIPT_CONTENT_NO_ANCHOR_STYLE : undefined}
role="log"
// Live only once the historical reveal has settled: chunks of replayed history
// mounting during a stream would otherwise be announced as fresh output.
aria-live={canInterrupt && revealSettled ? "polite" : "off"}
aria-busy={canInterrupt || isHydratingTranscript}
aria-label="Conversation transcript"
className={cn(
// `plan-toc-aware` opts only the centered max-w transcript into the
// sticky plan TOC layout. In `chatTranscriptFullWidth` mode the plan
// already fills the available width, so the TOC would either
// overlap content or get clipped by `overflow-x-hidden`.
// `w-full` is required in the centered mode because auto cross-axis
// margins disable flex-item stretch.
chatTranscriptFullWidth ? "w-full" : "plan-toc-aware max-w-chat mx-auto w-full",
// Push the dock to the bottom for short transcripts without reserving
// loading space: cached replay overlays the dock edge, not transcript rows.
"flex-1",
// Only the empty/centered placeholder fills height (as a flex column
// so the placeholder's flex-1 centering works). The hydration
// skeleton renders in normal top-aligned transcript flow so it sits
// where real messages will, avoiding a jump when hydration completes.
showEmptyTranscriptPlaceholder && "flex flex-col"
)}
>
{showTranscriptHydrationPlaceholder ? (
<TranscriptHydrationSkeleton workspaceId={workspaceId} />
) : showEmptyTranscriptPlaceholder ? (
<div className="text-placeholder flex h-full flex-1 flex-col items-center justify-center text-center [&_h3]:m-0 [&_h3]:mb-2.5 [&_h3]:text-base [&_h3]:font-medium [&_p]:m-0 [&_p]:text-[13px]">
<h3>No Messages Yet</h3>
<p>Send a message below to begin</p>
{hasRepository && (
<p className="text-muted mt-5 flex items-start gap-2 text-xs">
<Lightbulb aria-hidden="true" className="mt-0.5 h-3 w-3 shrink-0" />
<span>
Tip: Add a{" "}
<code className="bg-inline-code-dark-bg text-code-string rounded-[3px] px-1.5 py-0.5 font-mono text-[11px]">
.xum/init
</code>{" "}
hook to your project to run setup commands
<br />
(e.g., install dependencies, build) when creating new workspaces
</span>
</p>
)}
</div>
) : (
<BashCollapsedSummaryModeProvider>
<MessageListProvider
value={messageListContextValue}
isTranscriptBackfilling={isTranscriptBackfillingDuringStream}
>
{shouldRenderLoadOlderMessagesButton && (
<div className="flex justify-center py-3">
<TooltipIfPresent
tooltip={`Load older messages (${loadOlderMessagesShortcutLabel})`}
side="top"
>
<button
type="button"
onClick={handleLoadOlderHistory}
disabled={isLoadingOlderHistoryPage}
className="text-muted hover:text-foreground text-xs underline underline-offset-2 transition-colors disabled:opacity-50"
>
{isLoadingOlderHistoryPage ? "Loading..." : "Load older messages"}
</button>
</TooltipIfPresent>
</div>
)}
<TranscriptBundleRows
workspaceId={workspaceId}
messages={revealedMessages}
indexOffset={revealFromIndex}
bundles={transcriptBundles}
renderMessageAtIndex={renderMessageAtIndex}
/>
</MessageListProvider>
</BashCollapsedSummaryModeProvider>
)}
<TranscriptTailStackLane items={transcriptTailItems} />
</div>
{/* Bottom anchor: a 0-height sibling of the transcript content. While
locked it is the sole `overflow-anchor: auto` element, so native CSS
scroll anchoring keeps it (and therefore the bottom) pinned as rows
append above it. It sits between the transcript content and the
in-flow composer dock: appends grow content ABOVE it (anchoring
compensates), while dock growth happens BELOW it (covered by the
scrollport-children ResizeObserver in useAutoScroll, which re-pins
before paint). */}
<div
ref={sentinelRef}
data-testid="transcript-bottom-sentinel"
aria-hidden="true"
className="h-0 w-full"
style={TRANSCRIPT_BOTTOM_SENTINEL_STYLE}
/>
<PerfRenderMarker id="chat-pane.input">
{/* The composer dock is in-flow scroll content stuck to the scrollport
bottom: clearance for the last message is reserved by normal flow
layout in the same pass as any dock height change (no measured
--composer-h channel to lag a frame behind and tear), while
`sticky` keeps the composer visually pinned over the transcript
when the user scrolls up. `mx-[-15px]` cancels the scrollport's
gutter so the dock reaches the edges (short of any reserved overlay
room on the right); `whitespace-normal
break-normal` reset the transcript text inheritance. clientHeight
of the scrollport never changes with dock height (send-flash
invariant). `bg-surface-primary` keeps transcript content from
showing through gaps between decoration banners. */}
<ChatDockColumnProvider value={chatTranscriptFullWidth}>
<div
ref={composerDockRef}
data-testid="chat-composer-dock"
className="bg-surface-primary sticky bottom-0 z-10 mx-[-15px] break-normal whitespace-normal"
style={COMPOSER_DOCK_STYLE}
>
{/* An active turn does not mean history has loaded. Keep the dock shimmer
visible alongside turn controls whenever the full skeleton is absent. */}
{isHydratingTranscript && !showTranscriptHydrationPlaceholder && (
<div
role="status"
data-testid="transcript-loading-status"
className="pointer-events-none absolute inset-x-0 top-0 z-20 h-1 overflow-hidden"
>
<Skeleton
variant="shimmer"
className="bg-muted/30 block h-full w-full rounded-none"
/>
<span className="sr-only">Loading messages...</span>
</div>
)}
{!autoScroll && (
<button
onClick={handleJumpToBottom}
type="button"
// Sit just above the composer dock (8px gap), tracking its live
// height through normal layout instead of a measured offset.
className="assistant-chip font-primary text-foreground hover:assistant-chip-hover absolute bottom-full left-1/2 z-20 mb-2 -translate-x-1/2 cursor-pointer rounded-[20px] px-2 py-1 text-xs font-medium shadow-[0_4px_12px_rgba(0,0,0,0.3)] backdrop-blur-[1px] transition-transform duration-200 hover:scale-105 active:scale-95"
>
Jump to bottom{" "}
<span className="mobile-hide-shortcut-hints">
({formatKeybind(KEYBINDS.JUMP_TO_BOTTOM)})
</span>
</button>
)}
{transcriptOnly ? (
// Transcript-only workspaces keep their historical transcript, but the whole
// composer surface is replaced with a single read-only notice.
<>
{turnStatus}
{/* A failed cleanup can leave a flagged row whose checkout is gone (#4983). */}
{meta?.delegatedCreationInterrupted === true && (
<DelegatedCreationInterruptedBanner
key={workspaceId}
workspaceId={workspaceId}
workspaceName={workspaceName}
/>
)}
<TranscriptOnlyNoticePane
workspaceId={workspaceId}
heldInputs={workspaceState?.heldInputs ?? NO_HELD_INPUTS}
/>
</>
) : (
<ChatInputPane
turnStatus={turnStatus}
kind={meta?.kind}
workspaceId={workspaceId}
projectName={projectName}
workspaceName={workspaceName}
revealDecorations={revealDecorations}
isStreamStarting={isStreamStarting}
isTranscriptCaughtUp={isTranscriptCaughtUp}
transcriptReplayFailed={transcriptReplayFailed}
runtimeConfig={runtimeConfig}
isPreStreamAgentTask={isPreStreamAgentTask}
delegatedCreationInterrupted={meta?.delegatedCreationInterrupted === true}
preStreamAgentTaskStatus={
meta?.taskStatus === "starting" ? "starting" : "queued"
}
isCompacting={isCompacting}
shouldShowPinnedTodoList={shouldShowPinnedTodoList}
shouldShowReviewsBanner={shouldShowReviewsBanner}
canInterrupt={canInterrupt}
autoCompactionResult={autoCompactionResult}
shouldShowCompactionWarning={shouldShowCompactionWarning}
rolloverEnabled={rolloverEnabled}
contextSwitchWarning={contextSwitchWarning}
onContextSwitchCompact={handleContextSwitchCompact}
onContextSwitchDismiss={handleContextSwitchDismiss}
onModelChange={handleModelChange}
onMessageSendStarted={handleMessageSendStarted}
onMessageSent={handleMessageSent}
onResetContext={handleResetContext}
onTruncateHistory={handleClearHistory}
editingMessage={editingMessage}
onCancelEdit={handleCancelEdit}
onEditingMessageChange={updateEditingMessage}
onEditLastUserMessage={handleEditLastUserMessageClick}
onEditSendPendingChange={handleEditSendPendingChange}
onChatInputReady={handleChatInputReady}
queuedMessage={workspaceState?.queuedMessage ?? null}
heldInputs={workspaceState?.heldInputs ?? NO_HELD_INPUTS}
onEditQueuedMessage={() => void handleEditQueuedMessage()}
onSendQueuedImmediately={
workspaceState?.canInterrupt ? handleSendQueuedImmediately : undefined
}
onQueuedDispatchModeChange={handleQueuedDispatchModeChange}
onQueuedActionError={handleQueuedActionError}
queuedActionError={queuedActionError}
onClearQueuedActionError={clearQueuedActionError}
reviews={reviews}
onCheckReviews={handleCheckReviews}
/>
)}
</div>
</ChatDockColumnProvider>
</PerfRenderMarker>
</div>
{transcriptContextMenu.menu}
</div>
</PerfRenderMarker>
</>
);
};

Check warning on line 1792 in src/browser/components/ChatPane/ChatPane.tsx

View workflow job for this annotation

GitHub Actions / Quality / CRAP

CRAP crap-tax (warning)

src/browser/components/ChatPane/ChatPane.tsx:345-1792 ChatPaneContent: touched hotspot, CC 235 unchanged: consider shrinking it

const TranscriptOnlyNoticePane: React.FC<{
workspaceId: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useRef, useState } from "react";
import { Check, Loader2, TriangleAlert, Trash2 } from "lucide-react";
import { ChatDockSurface } from "@/browser/components/ChatPane/chatDockColumn";
import { useAPI } from "@/browser/contexts/API";
import { useLiveAPI } from "@/browser/contexts/API";
import {
useConfirmDialog,
type ConfirmDialogOptions,
Expand Down Expand Up @@ -41,7 +41,7 @@ export interface DelegatedCreationInterruptedNoticeProps {
export const DelegatedCreationInterruptedNotice: React.FC<
DelegatedCreationInterruptedNoticeProps
> = (props) => {
const { api } = useAPI();
const liveApi = useLiveAPI();
const [pendingAction, setPendingAction] = useState<BannerAction | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
// Synchronous guard: a second click can land before the disabled state renders.
Expand Down Expand Up @@ -79,8 +79,8 @@ export const DelegatedCreationInterruptedNotice: React.FC<

const keep = () =>
run("keep", async () => {
if (!api) return "Not connected to the backend";
const result = await api.workspace.keepInterruptedDelegatedWorkspace({
if (!liveApi) return "Not connected to the backend";
const result = await liveApi.workspace.keepInterruptedDelegatedWorkspace({
workspaceId: props.workspaceId,
});
return result.success ? null : result.error;
Expand Down
10 changes: 5 additions & 5 deletions src/browser/components/ProjectSidebar/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import {
isEditableElement,
KEYBINDS,
} from "@/browser/utils/ui/keybinds";
import { useAPI } from "@/browser/contexts/API";
import { useAPI, useLiveAPI } from "@/browser/contexts/API";
import {
CUSTOM_EVENTS,
getStorageChangeEvent,
Expand Down Expand Up @@ -662,18 +662,18 @@ function SidebarTitleEditKeybinds(props: {
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
}) {
const { requestEdit, wrapGenerateTitle } = useTitleEdit();
const { api } = useAPI();
const liveApi = useLiveAPI();

const regenerateTitleForWorkspace = useCallback(
(workspaceId: string) => {
wrapGenerateTitle(workspaceId, () => {
if (!api) {
if (!liveApi) {
return Promise.resolve({ success: false, error: "Not connected to server" });
}
return api.workspace.regenerateTitle({ workspaceId });
return liveApi.workspace.regenerateTitle({ workspaceId });
});
},
[wrapGenerateTitle, api]
[wrapGenerateTitle, liveApi]
);

useEffect(() => {
Expand Down
Loading
Loading