refactor(mobile): delete the seam surface with no production reader

Each was confirmed to have zero non-test readers before and after:

- HostSessionTerminalOperations.subscribe, its adapter body and its test. The
  live stream still calls subscribeMobileTerminalSafely directly, as on main,
  so the method existed only to be tested.
- pasteImages and releaseImages, declared on the native-chat interface and
  never implemented or called.
- explicitQuickCommandScope, exported with no callers.
- The quick-command snapshot's totalCount and repoId, which nothing read, and
  with them the workspaceId argument that existed only to compute repoId. The
  hook takes { operations, enabled } again, as main took { client, enabled }.
- The web-artifact result variant, line and column on the resolve request,
  rename's third parameter and the directory variant's truncated. The
  legacy-list variant's truncated stays: the panel reads it.

Deleting the terminal subscribe also brings the parity oracle's identity
fields to 14, matching main exactly.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-09 12:46:42 -04:00
parent 3924105b72
commit d0994be78e
14 changed files with 26 additions and 143 deletions
@@ -5,7 +5,6 @@ export type HostFileExplorerDirectoryResult =
| {
kind: 'directory'
entries: MobileDirEntry[]
truncated: boolean
}
| {
kind: 'legacy-list'
@@ -105,7 +105,6 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
visible={showQuickCommands && quickCommandsSupported === true}
onClose={() => setShowQuickCommands(false)}
operations={sessionOperations?.quickCommand ?? null}
workspaceId={worktreeId}
repoId={
isFolderWorkspaceRoute || isFloatingWorkspaceRoute
? null
@@ -24,7 +24,6 @@ type Props = {
visible: boolean
onClose: () => void
operations: HostSessionQuickCommandOperations | null
workspaceId: string
repoId: string | null
repoName: string | null
onLaunch: (command: TerminalQuickCommand) => boolean
@@ -36,14 +35,12 @@ export function QuickCommandsSheet({
visible,
onClose,
operations,
workspaceId,
repoId,
repoName,
onLaunch
}: Props) {
const { commands, loading, ready, error, persist } = useQuickCommands({
operations,
workspaceId,
enabled: visible
})
const [view, setView] = useState<SheetView>('list')
@@ -44,13 +44,6 @@ export type HostSessionNativeChatOperations = {
beforeOffset?: number
): Promise<HostSessionNativeChatReadResult>
stop(target: HostSessionNativeChatTarget, deadline?: number): Promise<MobileNativeChatSendOutcome>
pasteImages?(
target: HostSessionNativeChatTarget,
references: readonly string[],
deadline?: number,
followedByText?: boolean
): Promise<boolean>
releaseImages?(target: HostSessionNativeChatTarget, references: readonly string[]): Promise<void>
/** Drops the legacy full-inventory fallback's cached listing for a workspace. Without it a
* second visit inside one connection serves the first read's inventory, so a file created in
* between is missing from `@` autocomplete. */
@@ -1,25 +1,11 @@
import type {
TerminalQuickCommand,
TerminalQuickCommandScope
} from '../../../src/shared/terminal-quick-command-types'
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
import type { TerminalQuickCommandMutation } from '../terminal/quick-commands'
export type HostSessionQuickCommandSnapshot = {
commands: TerminalQuickCommand[]
totalCount: number
repoId: string | null
}
export type HostSessionQuickCommandOperations = {
snapshot(workspaceId: string, signal?: AbortSignal): Promise<HostSessionQuickCommandSnapshot>
mutate(
workspaceId: string,
mutation: TerminalQuickCommandMutation
): Promise<HostSessionQuickCommandSnapshot>
}
export function explicitQuickCommandScope(
scope: TerminalQuickCommandScope | undefined
): TerminalQuickCommandScope {
return scope?.type === 'repo' ? { type: 'repo', repoId: scope.repoId } : { type: 'global' }
snapshot(signal?: AbortSignal): Promise<HostSessionQuickCommandSnapshot>
mutate(mutation: TerminalQuickCommandMutation): Promise<HostSessionQuickCommandSnapshot>
}
@@ -13,14 +13,6 @@ export type HostSessionTerminalFileTarget =
grantId: string
workspaceId?: string
}
| {
kind: 'web-artifact'
/** The terminal text the desktop re-resolves for every chunk read. */
pathText: string
displayName: string
previewKind: 'text' | 'raster'
workspaceId?: string
}
export type HostSessionTerminalFileResolveRequest = {
workspaceId: string
@@ -28,8 +20,6 @@ export type HostSessionTerminalFileResolveRequest = {
pathText: string
cwd: string | null
nativeChatContext: RuntimeNativeChatFileContext | null
line: number | null
column: number | null
}
export type HostSessionTerminalFileOperations = {
@@ -52,11 +52,6 @@ export type HostSessionTerminalSubscribeArgs = {
}
export type HostSessionTerminalOperations = {
subscribe(
args: HostSessionTerminalSubscribeArgs,
onEvent: (event: HostSessionTerminalStreamEvent) => void,
onError: () => void
): () => void
sendInput(
terminalId: string,
text: string,
@@ -70,5 +65,5 @@ export type HostSessionTerminalOperations = {
clientId: string | null
): Promise<boolean>
clear(terminalId: string): Promise<boolean>
rename(terminalId: string, title: string, workspaceId: string): Promise<boolean>
rename(terminalId: string, title: string): Promise<boolean>
}
+1 -3
View File
@@ -77,9 +77,7 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
terminalHandle,
pathText: options.pathText,
cwd,
nativeChatContext: options.nativeChatContext ?? null,
line: options.line,
column: options.column
nativeChatContext: options.nativeChatContext ?? null
})
if (!resolved) {
reportOpenFailure(options)
@@ -70,7 +70,7 @@ const HEAD_CALLBACK_BODY_SHA256 = 'e66e6436cdb9a66e870c06fdfc140106502fbeddd8db4
const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13'
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
const HEAD_NESTED_FUNCTION_SHA256 =
'3b75d854a92b086907571f4c877961bd429f76e3826979cee5b0e94ed2007c8e'
'ad78ecb8bd61cd0cc3a7037bbd7dcc06b0fbe0de62b23090b8350c2947d65c3d'
const HEAD_NATIVE_REGISTRATION_SHA256 =
'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e'
const HEAD_NATIVE_REMOVAL_SHA256 =
@@ -79,13 +79,13 @@ const HEAD_TIMER_CREATION_SHA256 =
'1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b'
const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116'
const HEAD_RUNTIME_STRING_SHA256 =
'2f78f3e8f835b3a786c8940dabc127d0d131e909c0e736a4a341c5d50ce35059'
'58ee753e4fc849c693aa6ebb71b2ff7c38dbbba50977cacc737b03c4bb2a7224'
const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5'
const HEAD_LEAF_JSX_SHA256 = 'd5f1ef0db57c63eb3e4ee7c98e8483bc21882a151ce0ca24e42c7d1234e1dace'
const HEAD_LEAF_JSX_SHA256 = '15ea1af8ae1a28a4a5f2ea31c2aac908fc24a0fe68923733889f1d40d0c8e3a3'
const HEAD_STYLE_REFERENCE_SHA256 =
'295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a'
const HEAD_IDENTITY_FIELD_SHA256 =
'2084e23285fc128e02ffbf751092ffc834b2d257c5c4bd782769f0bf97d2f161'
'89bdd544369e41829581e80d320112019f3127fd3529c810d9651e5ece37338b'
const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512'
const HEAD_CAPABILITY_SHA256 = 'ca219f7909a091717110b823d5b94a20770ad3ae51894e0fa765e8628309392d'
@@ -529,9 +529,7 @@ describe('mobile session route extraction parity', () => {
)
expect(hash(native.cleanups)).toBe(HEAD_TIMER_CLEANUP_SHA256)
const compatibility = readCompatibilityFacts(definitions)
// 15, not main's 14: the seam moved two hook-side `deviceToken` fields into three
// adapter-side `client: { id }` builders. Every binding main pinned is still pinned.
expect(compatibility.identityFields).toHaveLength(15)
expect(compatibility.identityFields).toHaveLength(14)
expect(hash(compatibility.identityFields)).toBe(HEAD_IDENTITY_FIELD_SHA256)
expect(compatibility.navigation).toHaveLength(6)
expect(hash(compatibility.navigation)).toBe(HEAD_NAVIGATION_SHA256)
@@ -541,7 +539,7 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
expect(strings).toHaveLength(593)
expect(strings).toHaveLength(591)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(124)
@@ -5,8 +5,6 @@ import type {
HostSessionQuickCommandOperations,
HostSessionQuickCommandSnapshot
} from './host-session-quick-command-operations'
import { isFloatingWorkspaceWorktreeId } from './floating-workspace'
import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers'
const LOAD_CUTOVER_MAX_RETRIES = 5
@@ -14,16 +12,16 @@ export function nativeHostSessionQuickCommandOperations(
client: RpcClient
): HostSessionQuickCommandOperations {
return {
async snapshot(workspaceId, signal) {
async snapshot(signal) {
// The ok check sits outside the retry, as it did before this seam existed: a refusal
// envelope is an answer, and replaying it would depend on the host's error text.
const response = await loadWithCutoverRetry(client, signal)
if (!response.ok) {
throw new Error(response.error.message || 'Failed to load quick commands')
}
return quickCommandSnapshot(response.result, workspaceId, 'Failed to load quick commands')
return quickCommandSnapshot(response.result, 'Failed to load quick commands')
},
async mutate(workspaceId, mutation) {
async mutate(mutation) {
// Why no cutover retry here: a quick-command mutation is not idempotent, so a replay
// after a logical cutover could apply the same edit twice.
const response = await client.sendRequest('settings.updateTerminalQuickCommands', {
@@ -32,7 +30,7 @@ export function nativeHostSessionQuickCommandOperations(
if (!response.ok) {
throw new Error(response.error.message || 'Failed to save quick command')
}
return quickCommandSnapshot(response.result, workspaceId, 'Failed to save quick command')
return quickCommandSnapshot(response.result, 'Failed to save quick command')
}
}
}
@@ -55,7 +53,6 @@ async function loadWithCutoverRetry(client: RpcClient, signal?: AbortSignal) {
function quickCommandSnapshot(
result: unknown,
workspaceId: string,
invalidResultMessage: string
): HostSessionQuickCommandSnapshot {
const commands = parseNormalizedTerminalQuickCommands(
@@ -64,12 +61,5 @@ function quickCommandSnapshot(
if (!commands) {
throw new Error(invalidResultMessage)
}
return {
commands,
totalCount: commands.length,
repoId:
workspaceId.startsWith('folder:') || isFloatingWorkspaceWorktreeId(workspaceId)
? null
: getRepoIdFromMobileWorktreeId(workspaceId)
}
return { commands }
}
@@ -3,47 +3,14 @@ import type { RpcClient } from '../transport/rpc-client'
import { nativeHostSessionTerminalOperations } from './native-host-session-terminal-operations'
describe('native host session terminal operations', () => {
it('preserves the existing mobile subscription and input RPC semantics', async () => {
const unsubscribe = vi.fn()
let onData: ((event: unknown) => void) | null = null
const subscribe = vi.fn((_method, _params, listener) => {
onData = listener
return unsubscribe
})
it('preserves the existing mobile terminal input RPC semantics', async () => {
const sendRequest = vi
.fn()
.mockResolvedValue({ ok: true, result: { send: { accepted: true } } })
const operations = nativeHostSessionTerminalOperations({
subscribe,
sendRequest
} as unknown as RpcClient)
const onEvent = vi.fn()
const cleanup = operations.subscribe(
{
workspaceId: 'workspace-1',
terminalId: 'terminal-native-1',
clientId: 'device-1',
viewport: { cols: 90, rows: 30 },
visible: true,
capabilities: { terminalBinaryStream: 1 }
},
onEvent,
vi.fn()
)
onData?.({ type: 'data', chunk: 'hello' })
expect(subscribe).toHaveBeenCalledWith(
'terminal.subscribe',
{
terminal: 'terminal-native-1',
client: { id: 'device-1', type: 'mobile' },
viewport: { cols: 90, rows: 30 },
capabilities: { terminalBinaryStream: 1 }
},
expect.any(Function)
)
expect(onEvent).toHaveBeenCalledWith({ type: 'data', chunk: 'hello' })
await expect(operations.sendInput('terminal-native-1', 'ls', true, 'device-1')).resolves.toBe(
true
)
@@ -60,9 +27,7 @@ describe('native host session terminal operations', () => {
await expect(
operations.setDisplayMode('terminal-native-1', 'auto', { cols: 90, rows: 30 }, 'device-1')
).resolves.toBe(true)
await expect(
operations.rename('terminal-native-1', 'Build', 'workspace-native-1')
).resolves.toBe(true)
await expect(operations.rename('terminal-native-1', 'Build')).resolves.toBe(true)
await expect(operations.clear('terminal-native-1')).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledWith('terminal.setDisplayMode', {
terminal: 'terminal-native-1',
@@ -77,8 +42,5 @@ describe('native host session terminal operations', () => {
expect(sendRequest).toHaveBeenCalledWith('terminal.clearBuffer', {
terminal: 'terminal-native-1'
})
cleanup()
expect(unsubscribe).toHaveBeenCalledOnce()
})
})
@@ -2,26 +2,11 @@ import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-respons
import { TERMINAL_INPUT_SEND_OPTIONS } from '../terminal/terminal-send-request'
import type { RpcClient } from '../transport/rpc-client'
import type { HostSessionTerminalOperations } from './host-session-terminal-operations'
import { subscribeMobileTerminalSafely } from './mobile-terminal-stream-subscribe'
export function nativeHostSessionTerminalOperations(
client: RpcClient
): HostSessionTerminalOperations {
return {
subscribe(args, onEvent, onError) {
const unsubscribe = subscribeMobileTerminalSafely(
client,
{
terminal: args.terminalId,
...(args.clientId ? { client: { id: args.clientId, type: 'mobile' as const } } : {}),
viewport: args.viewport,
capabilities: args.capabilities
},
(event) => onEvent(event as Parameters<typeof onEvent>[0]),
onError
)
return unsubscribe
},
async sendInput(terminalId, text, enter, clientId) {
return client
.sendRequest(
@@ -40,7 +40,7 @@ export function useMobileSessionCloseActions(scope: MobileSessionContentCreateAc
try {
const title = value.trim()
if (await sessionOperations.terminal.rename(target.handle, title, worktreeId)) {
if (await sessionOperations.terminal.rename(target.handle, title)) {
setTerminals((prev) => {
const next = prev.map((terminal) =>
terminal.handle === target.handle
+7 -16
View File
@@ -8,7 +8,6 @@ import type { HostSessionQuickCommandOperations } from './host-session-quick-com
type Args = {
operations: HostSessionQuickCommandOperations | null
workspaceId: string
// Fetch only while the sheet is open — quick commands are settings data we
// don't need to keep hydrated for every session screen.
enabled: boolean
@@ -31,14 +30,13 @@ type PendingMutation = {
type MutationContext = {
operations: HostSessionQuickCommandOperations
workspaceId: string
confirmed: TerminalQuickCommand[]
pending: PendingMutation[]
queue: Promise<void>
nextMutationId: number
}
export function useQuickCommands({ operations, workspaceId, enabled }: Args): QuickCommandsState {
export function useQuickCommands({ operations, enabled }: Args): QuickCommandsState {
const [commands, setCommands] = useState<TerminalQuickCommand[]>([])
const [loading, setLoading] = useState(false)
const [ready, setReady] = useState(false)
@@ -53,11 +51,10 @@ export function useQuickCommands({ operations, workspaceId, enabled }: Args): Qu
return
}
let mutationContext = mutationContextRef.current
if (mutationContext?.operations !== operations || mutationContext.workspaceId !== workspaceId) {
if (mutationContext?.operations !== operations) {
// A request for an old host must not delay or update mutations on a new one.
mutationContext = {
operations,
workspaceId,
confirmed: [],
pending: [],
queue: Promise.resolve(),
@@ -89,7 +86,7 @@ export function useQuickCommands({ operations, workspaceId, enabled }: Args): Qu
) {
return
}
const snapshot = await operations.snapshot(workspaceId, abortController.signal)
const snapshot = await operations.snapshot(abortController.signal)
if (
stale ||
operationId !== operationIdRef.current ||
@@ -124,20 +121,14 @@ export function useQuickCommands({ operations, workspaceId, enabled }: Args): Qu
stale = true
abortController.abort()
}
}, [enabled, operations, workspaceId])
}, [enabled, operations])
const persist = useCallback(
async (commandMutation: TerminalQuickCommandMutation) => {
// Why: the loaded list is the optimistic/rollback baseline; mutating
// before it arrives would make failure recovery show invented state.
const mutationContext = mutationContextRef.current
if (
!operations ||
loading ||
!ready ||
mutationContext?.operations !== operations ||
mutationContext.workspaceId !== workspaceId
) {
if (!operations || loading || !ready || mutationContext?.operations !== operations) {
return false
}
const mutation: PendingMutation = {
@@ -157,7 +148,7 @@ export function useQuickCommands({ operations, workspaceId, enabled }: Args): Qu
try {
// Why: an invalid success payload throws inside the adapter rather than
// confirming [] — a later full-list mutation would erase live commands.
const snapshot = await operations.mutate(workspaceId, commandMutation)
const snapshot = await operations.mutate(commandMutation)
mutationContext.confirmed = snapshot.commands
succeeded = true
return true
@@ -191,7 +182,7 @@ export function useQuickCommands({ operations, workspaceId, enabled }: Args): Qu
)
return await request
},
[loading, operations, ready, workspaceId]
[loading, operations, ready]
)
return { commands, loading, ready, error, persist }