mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
Fix mobile subscription and preview review issues
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user