From af32782e6409f3c234e866d7da9202c7d005f62c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 30 Mar 2026 08:41:34 -0400 Subject: [PATCH 1/2] Refactor AppHost out of App --- benchmarks/highlight-prefetch.ts | 2 +- benchmarks/large-stream.ts | 2 +- src/main.tsx | 2 +- src/ui/App.tsx | 81 +--------------------------- src/ui/AppHost.tsx | 92 ++++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 82 deletions(-) create mode 100644 src/ui/AppHost.tsx diff --git a/benchmarks/highlight-prefetch.ts b/benchmarks/highlight-prefetch.ts index 88f6d57..aeaa128 100644 --- a/benchmarks/highlight-prefetch.ts +++ b/benchmarks/highlight-prefetch.ts @@ -5,7 +5,7 @@ import React from "react"; import { testRender } from "@opentui/react/test-utils"; import { parseDiffFromFile } from "@pierre/diffs"; import { act } from "react"; -import { AppHost } from "../src/ui/App"; +import { AppHost } from "../src/ui/AppHost"; import type { AppBootstrap, DiffFile } from "../src/core/types"; function createDiffFile(index: number, marker: string): DiffFile { diff --git a/benchmarks/large-stream.ts b/benchmarks/large-stream.ts index 36bed1b..21c739c 100644 --- a/benchmarks/large-stream.ts +++ b/benchmarks/large-stream.ts @@ -4,7 +4,7 @@ import { performance } from "perf_hooks"; import React from "react"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { AppHost } from "../src/ui/App"; +import { AppHost } from "../src/ui/AppHost"; import { createLargeSplitStreamBootstrap, DEFAULT_FILE_COUNT, diff --git a/src/main.tsx b/src/main.tsx index 61fda95..d3c5057 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,7 +7,7 @@ import { pagePlainText } from "./core/pager"; import { shutdownSession } from "./core/shutdown"; import { prepareStartupPlan } from "./core/startup"; import { resolveStartupUpdateNotice } from "./core/updateNotice"; -import { AppHost } from "./ui/App"; +import { AppHost } from "./ui/AppHost"; import { HunkHostClient } from "./mcp/client"; import { serveHunkMcpServer } from "./mcp/server"; import { createInitialSessionSnapshot, createSessionRegistration } from "./mcp/sessionRegistration"; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d398906..7f982a7 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -16,17 +16,9 @@ import { useState, useRef, } from "react"; -import { resolveConfiguredCliInput } from "../core/config"; -import { loadAppBootstrap } from "../core/loaders"; -import { resolveRuntimeCliInput } from "../core/terminal"; import type { AppBootstrap, CliInput, LayoutMode } from "../core/types"; import { canReloadInput, computeWatchSignature } from "../core/watch"; -import type { UpdateNotice } from "../core/updateNotice"; import { HunkHostClient } from "../mcp/client"; -import { - createInitialSessionSnapshot, - updateSessionRegistration, -} from "../mcp/sessionRegistration"; import type { ReloadedSessionResult } from "../mcp/types"; import { MenuBar } from "./components/chrome/MenuBar"; import { StatusBar } from "./components/chrome/StatusBar"; @@ -35,7 +27,6 @@ import { FilesPane } from "./components/panes/FilesPane"; import { PaneDivider } from "./components/panes/PaneDivider"; import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge"; import { useMenuController } from "./hooks/useMenuController"; -import { useStartupUpdateNotice } from "./hooks/useStartupUpdateNotice"; import { buildAppMenus } from "./lib/appMenus"; import { buildSidebarEntries, filterReviewFiles, mergeFileAnnotationsByFileId } from "./lib/files"; import { buildAnnotatedHunkCursors, buildHunkCursors, findNextHunkCursor } from "./lib/hunks"; @@ -85,7 +76,7 @@ function withCurrentViewOptions( } /** Orchestrate global app state, layout, navigation, and pane coordination. */ -function App({ +export function App({ bootstrap, hostClient, noticeText, @@ -1045,72 +1036,4 @@ function App({ ); } -/** Keep one live Hunk app mounted while allowing daemon-driven session reloads. */ -export function AppHost({ - bootstrap, - hostClient, - onQuit = () => process.exit(0), - startupNoticeResolver, -}: { - bootstrap: AppBootstrap; - hostClient?: HunkHostClient; - onQuit?: () => void; - startupNoticeResolver?: () => Promise; -}) { - const [activeBootstrap, setActiveBootstrap] = useState(bootstrap); - const [appVersion, setAppVersion] = useState(0); - const startupNoticeText = useStartupUpdateNotice({ - enabled: !bootstrap.input.options.pager, - resolver: startupNoticeResolver, - }); - - const reloadSession = useCallback( - async (nextInput: CliInput, options?: { resetApp?: boolean; sourcePath?: string }) => { - const runtimeInput = resolveRuntimeCliInput(nextInput); - const configuredInput = resolveConfiguredCliInput(runtimeInput, { - cwd: options?.sourcePath, - }).input; - const nextBootstrap = await loadAppBootstrap(configuredInput, { - cwd: options?.sourcePath, - }); - const nextSnapshot = createInitialSessionSnapshot(nextBootstrap); - - let sessionId = "local-session"; - if (hostClient) { - const nextRegistration = updateSessionRegistration( - hostClient.getRegistration(), - nextBootstrap, - ); - sessionId = nextRegistration.sessionId; - hostClient.replaceSession(nextRegistration, nextSnapshot); - } - - setActiveBootstrap(nextBootstrap); - if (options?.resetApp !== false) { - setAppVersion((current) => current + 1); - } - - return { - sessionId, - inputKind: nextBootstrap.input.kind, - title: nextBootstrap.changeset.title, - sourceLabel: nextBootstrap.changeset.sourceLabel, - fileCount: nextBootstrap.changeset.files.length, - selectedFilePath: nextSnapshot.selectedFilePath, - selectedHunkIndex: nextSnapshot.selectedHunkIndex, - }; - }, - [hostClient], - ); - - return ( - - ); -} +export { AppHost } from "./AppHost"; diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx new file mode 100644 index 0000000..f4e3ed2 --- /dev/null +++ b/src/ui/AppHost.tsx @@ -0,0 +1,92 @@ +import { useCallback, useState } from "react"; +import { resolveConfiguredCliInput } from "../core/config"; +import { loadAppBootstrap } from "../core/loaders"; +import { resolveRuntimeCliInput } from "../core/terminal"; +import type { AppBootstrap, CliInput } from "../core/types"; +import type { UpdateNotice } from "../core/updateNotice"; +import { HunkHostClient } from "../mcp/client"; +import { + createInitialSessionSnapshot, + updateSessionRegistration, +} from "../mcp/sessionRegistration"; +import { App } from "./App"; +import { useStartupUpdateNotice } from "./hooks/useStartupUpdateNotice"; + +/** Keep one live Hunk app mounted while allowing daemon-driven session reloads. */ +export function AppHost({ + bootstrap, + hostClient, + onQuit = () => process.exit(0), + startupNoticeResolver, +}: { + bootstrap: AppBootstrap; + hostClient?: HunkHostClient; + onQuit?: () => void; + startupNoticeResolver?: () => Promise; +}) { + const [activeBootstrap, setActiveBootstrap] = useState(bootstrap); + const [appVersion, setAppVersion] = useState(0); + const startupNoticeText = useStartupUpdateNotice({ + enabled: !bootstrap.input.options.pager, + resolver: startupNoticeResolver, + }); + + const reloadSession = useCallback( + async (nextInput: CliInput, options?: { resetApp?: boolean; sourcePath?: string }) => { + // Re-run the same startup normalization pipeline used on first launch so reloads honor + // runtime defaults and config layering instead of assuming `nextInput` is already final. + // `sourcePath` matters for daemon-driven reloads that ask Hunk to reopen content from a + // different working directory than the process originally started in. + const runtimeInput = resolveRuntimeCliInput(nextInput); + const configuredInput = resolveConfiguredCliInput(runtimeInput, { + cwd: options?.sourcePath, + }).input; + const nextBootstrap = await loadAppBootstrap(configuredInput, { + cwd: options?.sourcePath, + }); + const nextSnapshot = createInitialSessionSnapshot(nextBootstrap); + + let sessionId = "local-session"; + if (hostClient) { + // Keep the daemon-facing session registration in sync with whatever the UI is about to + // show. Replacing both registration and snapshot here means external session commands see + // the new source, title, and selection baseline immediately after reload. + const nextRegistration = updateSessionRegistration( + hostClient.getRegistration(), + nextBootstrap, + ); + sessionId = nextRegistration.sessionId; + hostClient.replaceSession(nextRegistration, nextSnapshot); + } + + setActiveBootstrap(nextBootstrap); + if (options?.resetApp !== false) { + // Bumping the key forces a full App remount. Callers that pass `resetApp: false` get a + // soft reload that preserves in-memory UI state like selection, filter text, and pane size. + setAppVersion((current) => current + 1); + } + + return { + sessionId, + inputKind: nextBootstrap.input.kind, + title: nextBootstrap.changeset.title, + sourceLabel: nextBootstrap.changeset.sourceLabel, + fileCount: nextBootstrap.changeset.files.length, + selectedFilePath: nextSnapshot.selectedFilePath, + selectedHunkIndex: nextSnapshot.selectedHunkIndex, + }; + }, + [hostClient], + ); + + return ( + + ); +} From 83e7a6d8238edfc5a806caf654891e90c67f720f Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 30 Mar 2026 08:48:24 -0400 Subject: [PATCH 2/2] Remove App/AppHost compatibility cycle --- src/ui/App.tsx | 2 -- test/app-interactions.test.tsx | 2 +- test/app-responsive.test.tsx | 2 +- test/ui-components.test.tsx | 2 +- test/ui-scroll-regression.test.tsx | 2 +- test/vertical-scrollbar.test.tsx | 2 +- 6 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 7f982a7..36dad82 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1035,5 +1035,3 @@ export function App({ ); } - -export { AppHost } from "./AppHost"; diff --git a/test/app-interactions.test.tsx b/test/app-interactions.test.tsx index cb76fd4..91d62e0 100644 --- a/test/app-interactions.test.tsx +++ b/test/app-interactions.test.tsx @@ -10,7 +10,7 @@ import type { HunkSessionRegistration, SessionServerMessage } from "../src/mcp/t import type { AppBootstrap, DiffFile, LayoutMode } from "../src/core/types"; const { loadAppBootstrap } = await import("../src/core/loaders"); -const { AppHost } = await import("../src/ui/App"); +const { AppHost } = await import("../src/ui/AppHost"); function createDiffFile( id: string, diff --git a/test/app-responsive.test.tsx b/test/app-responsive.test.tsx index d124e17..457f35e 100644 --- a/test/app-responsive.test.tsx +++ b/test/app-responsive.test.tsx @@ -4,7 +4,7 @@ import { parseDiffFromFile } from "@pierre/diffs"; import { act } from "react"; import type { AppBootstrap, DiffFile, LayoutMode } from "../src/core/types"; -const { AppHost } = await import("../src/ui/App"); +const { AppHost } = await import("../src/ui/AppHost"); function createDiffFile( id: string, diff --git a/test/ui-components.test.tsx b/test/ui-components.test.tsx index d0e8743..f9c32c7 100644 --- a/test/ui-components.test.tsx +++ b/test/ui-components.test.tsx @@ -5,7 +5,7 @@ import { act, createRef, type ReactNode } from "react"; import type { AppBootstrap, DiffFile } from "../src/core/types"; import { resolveTheme } from "../src/ui/themes"; -const { AppHost } = await import("../src/ui/App"); +const { AppHost } = await import("../src/ui/AppHost"); const { buildSidebarEntries } = await import("../src/ui/lib/files"); const { HelpDialog } = await import("../src/ui/components/chrome/HelpDialog"); const { FilesPane } = await import("../src/ui/components/panes/FilesPane"); diff --git a/test/ui-scroll-regression.test.tsx b/test/ui-scroll-regression.test.tsx index 79ef471..0293031 100644 --- a/test/ui-scroll-regression.test.tsx +++ b/test/ui-scroll-regression.test.tsx @@ -6,7 +6,7 @@ import type { AppBootstrap } from "../src/core/types"; mock.restore(); -const { AppHost } = await import("../src/ui/App"); +const { AppHost } = await import("../src/ui/AppHost"); function createScrollBootstrap(): AppBootstrap { const before = Array.from( diff --git a/test/vertical-scrollbar.test.tsx b/test/vertical-scrollbar.test.tsx index 0c42d58..b7169f1 100644 --- a/test/vertical-scrollbar.test.tsx +++ b/test/vertical-scrollbar.test.tsx @@ -4,7 +4,7 @@ import { parseDiffFromFile } from "@pierre/diffs"; import { act } from "react"; import type { AppBootstrap, DiffFile } from "../src/core/types"; -const { AppHost } = await import("../src/ui/App"); +const { AppHost } = await import("../src/ui/AppHost"); function createDiffFile(id: string, path: string, before: string, after: string): DiffFile { const metadata = parseDiffFromFile(