Fix mobile subscription and preview review issues

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-11 22:48:44 -04:00
co-authored by Orca
parent 3ffd068f42
commit 368c97149f
5 changed files with 141 additions and 111 deletions
+1 -4
View File
@@ -174,10 +174,7 @@ export default function MobileFileExplorerScreen() {
throw new Error(response.error?.message || 'Unable to open file')
}
triggerSelection()
router.replace({
pathname: '/h/[hostId]/session/[worktreeId]',
params: { hostId, worktreeId, name: name ?? '' }
})
router.back()
} catch (err) {
triggerError()
setError(err instanceof Error ? err.message : 'Unable to open file')
+11 -4
View File
@@ -1581,7 +1581,7 @@ export default function SessionScreen() {
useEffect(() => {
if (activeSessionTab?.type !== 'markdown') return
const doc = markdownDocs.get(activeSessionTab.id)
if (!doc || doc.status === 'error') {
if (!doc) {
void readMarkdownTab(activeSessionTab)
}
}, [activeSessionTab, markdownDocs, readMarkdownTab])
@@ -1589,7 +1589,7 @@ export default function SessionScreen() {
useEffect(() => {
if (activeSessionTab?.type !== 'file') return
const doc = fileDocs.get(activeSessionTab.id)
if (!doc || doc.status === 'error') {
if (!doc) {
void readFileTab(activeSessionTab)
}
}, [activeSessionTab, fileDocs, readFileTab])
@@ -2456,8 +2456,15 @@ export default function SessionScreen() {
const combined = drafts
.map((draft) => `# ${draft.title}\n\n${draft.content}`)
.join('\n\n---\n\n')
setLeaveDrafts(null)
void Clipboard.setStringAsync(combined).finally(() => router.back())
void Clipboard.setStringAsync(combined)
.then(() => {
setLeaveDrafts(null)
router.back()
})
.catch(() => {
triggerError()
showToast("Couldn't copy drafts", 1500)
})
}
},
{
+6 -4
View File
@@ -122,6 +122,7 @@ import {
} from '../git/repo'
import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree'
import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
import {
createSetupRunnerScript,
getEffectiveHooks,
@@ -1001,7 +1002,7 @@ export class OrcaRuntimeService {
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
const content = repo?.connectionId
? await this.readRemoteMobileFile(filePath, repo.connectionId)
: await readLocalMobileFile(filePath)
: await readLocalMobileFile(filePath, this.store as unknown as Store)
const truncated = truncateMobileFilePreview(content)
return {
@@ -7147,12 +7148,13 @@ function joinWorktreeRelativePath(rootPath: string, relativePath: string): strin
return posix.join(rootPath, ...normalizedRelativePath.split('/'))
}
async function readLocalMobileFile(filePath: string): Promise<string> {
const fileStat = await stat(filePath)
async function readLocalMobileFile(filePath: string, store: Store): Promise<string> {
const authorizedPath = await resolveAuthorizedPath(filePath, store)
const fileStat = await stat(authorizedPath)
// Why: mobile file previews are read-only convenience views; cap the read so
// opening a generated log or bundle cannot block the WebSocket like oversized scrollback.
const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1)
const handle = await open(filePath, 'r')
const handle = await open(authorizedPath, 'r')
try {
const buffer = Buffer.alloc(readLimit)
const { bytesRead } = await handle.read(buffer, 0, readLimit, 0)
+18 -9
View File
@@ -39,23 +39,31 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
name: 'session.tabs.subscribe',
params: WorktreeTabSelector,
handler: async (params, { runtime, connectionId }, emit) => {
const initial = await runtime.listMobileSessionTabs(params.worktree)
emit({ type: 'snapshot', ...initial })
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => {
if (snapshot.worktree === initial.worktree) {
emit({ type: 'updated', ...snapshot })
}
})
const subscriptionId = `session.tabs:${connectionId ?? 'local'}:${initial.worktree}`
let subscribedWorktree: string | null = null
let unsubscribe = (): void => {}
let closed = false
const subscriptionId = `session.tabs:${connectionId ?? 'local'}:${params.worktree}`
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
closed = true
unsubscribe()
emit({ type: 'end' })
},
connectionId
)
const initial = await runtime.listMobileSessionTabs(params.worktree)
if (closed) {
return
}
subscribedWorktree = initial.worktree
emit({ type: 'snapshot', ...initial })
unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => {
if (snapshot.worktree === subscribedWorktree) {
emit({ type: 'updated', ...snapshot })
}
})
}
}),
defineMethod({
@@ -63,6 +71,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
params: WorktreeTabSelector,
handler: async (params, { runtime, connectionId }) => {
const snapshot = await runtime.listMobileSessionTabs(params.worktree)
runtime.cleanupSubscription(`session.tabs:${connectionId ?? 'local'}:${params.worktree}`)
runtime.cleanupSubscription(`session.tabs:${connectionId ?? 'local'}:${snapshot.worktree}`)
return { unsubscribed: true }
}
+105 -90
View File
@@ -561,6 +561,31 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
let closed = false
let buffering = true
const pendingOutput: string[] = []
let unsubscribeData = (): void => {}
let unsubscribeResize = (): void => {}
let unsubscribeFit = (): void => {}
let resolveStream = (): void => {}
const streamClosed = new Promise<void>((resolve) => {
resolveStream = resolve
})
// Why: register cleanup before any mobile-fit or snapshot await. A phone
// can disconnect mid-subscribe; cleanup must still remove mobile presence.
const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
closed = true
unsubscribeData()
unsubscribeResize()
unsubscribeFit()
if (isMobile && clientId) {
runtime.handleMobileUnsubscribe(ptyId, clientId)
}
emit({ type: 'end' })
resolveStream()
},
connectionId
)
const sendFrame = (
opcode: TerminalStreamOpcode,
payload: Uint8Array<ArrayBufferLike> = new Uint8Array()
@@ -571,80 +596,86 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload }))
}
// Server-side auto-fit: resize PTY to phone dims before serializing scrollback
if (isMobile && clientId) {
await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
}
const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => {
try {
if (isMobile && clientId) {
await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
}
if (closed) {
return
}
if (buffering) {
pendingOutput.push(data)
unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => {
if (closed) {
return
}
if (buffering) {
pendingOutput.push(data)
return
}
sendBinary!(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId,
seq: cursor++,
payload: encodeTerminalStreamText(data)
})
)
})
const read = await runtime.readTerminal(params.terminal)
const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true)
if (closed) {
return
}
sendBinary!(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId,
seq: cursor++,
payload: encodeTerminalStreamText(data)
})
)
})
const size = runtime.getTerminalSize(ptyId)
const displayMode = runtime.getMobileDisplayMode(ptyId)
// Why: emit the current layout seq with the initial scrollback so
// the mobile client's stale-event filter knows the high-water mark.
// Undefined when the PTY has never transitioned (filter is fail-open).
// See docs/mobile-terminal-layout-state-machine.md.
const seq = runtime.getLayout(ptyId)?.seq
emit({
type: 'subscribed',
streamId,
lines: read.tail,
truncated: read.truncated,
cols: serialized?.cols ?? size?.cols,
rows: serialized?.rows ?? size?.rows,
displayMode,
seq
})
const snapshotStats = sendSnapshotFrames(sendFrame, {
kind: 'scrollback',
cols: serialized?.cols ?? size?.cols ?? 80,
rows: serialized?.rows ?? size?.rows ?? 24,
displayMode,
seq,
truncated: read.truncated,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
data: serialized?.data ?? ''
})
console.log('[mobile-terminal-stream] snapshot', {
terminal: params.terminal,
streamId,
kind: 'scrollback',
bytes: snapshotStats.bytes,
chunks: snapshotStats.chunks,
scrollbackRows: serialized?.scrollbackRows,
truncatedByByteBudget: serialized?.truncatedByByteBudget === true
})
buffering = false
for (const item of pendingOutput.splice(0)) {
sendBinary!(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId,
seq: cursor++,
payload: encodeTerminalStreamText(item)
})
)
}
const read = await runtime.readTerminal(params.terminal)
const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true)
const size = runtime.getTerminalSize(ptyId)
const displayMode = runtime.getMobileDisplayMode(ptyId)
// Why: emit the current layout seq with the initial scrollback so
// the mobile client's stale-event filter knows the high-water mark.
// Undefined when the PTY has never transitioned (filter is fail-open).
// See docs/mobile-terminal-layout-state-machine.md.
const seq = runtime.getLayout(ptyId)?.seq
emit({
type: 'subscribed',
streamId,
lines: read.tail,
truncated: read.truncated,
cols: serialized?.cols ?? size?.cols,
rows: serialized?.rows ?? size?.rows,
displayMode,
seq
})
const snapshotStats = sendSnapshotFrames(sendFrame, {
kind: 'scrollback',
cols: serialized?.cols ?? size?.cols ?? 80,
rows: serialized?.rows ?? size?.rows ?? 24,
displayMode,
seq,
truncated: read.truncated,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
data: serialized?.data ?? ''
})
console.log('[mobile-terminal-stream] snapshot', {
terminal: params.terminal,
streamId,
kind: 'scrollback',
bytes: snapshotStats.bytes,
chunks: snapshotStats.chunks,
scrollbackRows: serialized?.scrollbackRows,
truncatedByByteBudget: serialized?.truncatedByByteBudget === true
})
buffering = false
for (const item of pendingOutput.splice(0)) {
sendBinary!(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId,
seq: cursor++,
payload: encodeTerminalStreamText(item)
})
)
}
await new Promise<void>((resolve) => {
const unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => {
unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => {
// Why: true PTY geometry changes should be followed by the TUI's
// redraw output, not a full scrollback replay. The client resizes
// xterm geometry and consumes subsequent live output on this stream.
@@ -661,7 +692,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
})
// Legacy fit-override-changed for non-mobile (desktop) subscribers
const unsubscribeFit = !isMobile
unsubscribeFit = !isMobile
? runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
emit({
type: 'fit-override-changed',
@@ -671,28 +702,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
})
})
: () => {}
} catch (error) {
runtime.cleanupSubscription(subscriptionId)
throw error
}
// Why: composite subscriptionId per (terminal, clientId) so two
// mobile clients subscribing to the same terminal handle do not
// evict each other via registerSubscriptionCleanup's
// duplicate-key cleanup. See docs/mobile-presence-lock.md.
const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
closed = true
unsubscribeData()
unsubscribeResize()
unsubscribeFit()
if (isMobile && clientId) {
runtime.handleMobileUnsubscribe(ptyId, clientId)
}
emit({ type: 'end' })
resolve()
},
connectionId
)
})
await streamClosed
}
}),
defineMethod({