Files
orca/src/main/runtime/rpc/methods/session-tabs.ts
T
2026-06-14 01:48:26 -07:00

294 lines
8.8 KiB
TypeScript

import { z } from 'zod'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../../shared/types'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
const WorktreeTabSelector = z.object({
worktree: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing worktree selector'))
})
const SessionTabsUnsubscribe = WorktreeTabSelector.extend({
subscriptionId: z.string().min(1).optional()
})
const ActivateTab = WorktreeTabSelector.extend({
tabId: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing tab id')),
leafId: z.string().max(128).optional()
})
const CreateTerminalTab = WorktreeTabSelector.extend({
afterTabId: z.string().optional(),
targetGroupId: z.string().optional(),
command: z.string().optional(),
agent: z
.custom<TuiAgent>(isTuiAgent, {
message: 'Unknown agent preset'
})
.optional(),
activate: z.boolean().optional()
})
const MoveTabBase = {
worktree: WorktreeTabSelector.shape.worktree,
tabId: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing tab id')),
targetGroupId: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing target group id'))
} as const
const MoveTab = z.discriminatedUnion('kind', [
z
.object({
...MoveTabBase,
kind: z.literal('reorder'),
tabOrder: z.array(z.string().min(1)).min(1, 'Missing tab order')
})
.strict(),
z
.object({
...MoveTabBase,
kind: z.literal('move-to-group'),
index: z.number().int().nonnegative().optional()
})
.strict(),
z
.object({
...MoveTabBase,
kind: z.literal('split'),
splitDirection: z.enum(['left', 'right', 'up', 'down'])
})
.strict()
])
const SaveMarkdownTab = ActivateTab.extend({
baseVersion: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing base version')),
content: z.string()
})
export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'session.tabs.list',
params: WorktreeTabSelector,
handler: async (params, { runtime }) => runtime.listMobileSessionTabs(params.worktree)
}),
defineMethod({
name: 'session.tabs.listAll',
params: null,
handler: async (_params, { runtime }) => ({
snapshots: await runtime.listAllMobileSessionTabs()
})
}),
defineMethod({
name: 'session.tabs.activate',
params: ActivateTab,
handler: async (params, { runtime }) =>
runtime.activateMobileSessionTab(params.worktree, params.tabId, params.leafId)
}),
defineMethod({
name: 'session.tabs.close',
params: ActivateTab,
handler: async (params, { runtime }) =>
runtime.closeMobileSessionTab(params.worktree, params.tabId)
}),
defineMethod({
name: 'session.tabs.createTerminal',
params: CreateTerminalTab,
handler: async (params, { runtime }) =>
runtime.createMobileSessionTerminal(params.worktree, {
afterTabId: params.afterTabId,
targetGroupId: params.targetGroupId,
command: params.command,
agent: params.agent,
activate: params.activate
})
}),
defineMethod({
name: 'session.tabs.move',
params: MoveTab,
handler: async (params, { runtime }) => {
const base = {
tabId: params.tabId,
targetGroupId: params.targetGroupId
}
if (params.kind === 'reorder') {
return runtime.moveMobileSessionTab(params.worktree, {
...base,
kind: 'reorder',
tabOrder: params.tabOrder
})
}
if (params.kind === 'split') {
return runtime.moveMobileSessionTab(params.worktree, {
...base,
kind: 'split',
splitDirection: params.splitDirection
})
}
return runtime.moveMobileSessionTab(params.worktree, {
...base,
kind: 'move-to-group',
index: params.index
})
}
}),
defineStreamingMethod({
name: 'session.tabs.subscribe',
params: WorktreeTabSelector,
handler: async (params, { runtime, connectionId, requestId }, emit) => {
let subscribedWorktree: string | null = null
let unsubscribe = (): void => {}
let closed = false
let initialized = false
const initial = await runtime.listMobileSessionTabs(params.worktree)
if (closed) {
return
}
subscribedWorktree = initial.worktree
const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:${subscribedWorktree}`
const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix
// Why: shared-control can carry multiple subscribers for one worktree on
// one socket; include the RPC id so one subscriber cannot evict another.
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
closed = true
unsubscribe()
if (initialized) {
emit({ type: 'end' })
}
},
connectionId
)
if (closed) {
return
}
emit({ type: 'snapshot', ...initial })
initialized = true
if (closed) {
return
}
unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => {
if (snapshot.worktree === subscribedWorktree) {
emit({ type: 'updated', ...snapshot })
}
})
if (closed) {
unsubscribe()
}
}
}),
defineMethod({
name: 'session.tabs.unsubscribe',
params: SessionTabsUnsubscribe,
handler: async (params, { runtime, connectionId }) => {
const snapshot = await runtime.listMobileSessionTabs(params.worktree)
const connection = connectionId ?? 'local'
if (params.subscriptionId) {
runtime.cleanupSubscription(
`session.tabs:${connection}:${snapshot.worktree}:${params.subscriptionId}`
)
return { unsubscribed: true }
}
runtime.cleanupSubscription(`session.tabs:${connection}:${params.worktree}`)
runtime.cleanupSubscription(`session.tabs:${connection}:${snapshot.worktree}`)
runtime.cleanupSubscriptionsByPrefix(`session.tabs:${connection}:${snapshot.worktree}:`)
return { unsubscribed: true }
}
}),
defineStreamingMethod({
name: 'session.tabs.subscribeAll',
params: null,
handler: async (_params, { runtime, connectionId, requestId }, emit) => {
let unsubscribe = (): void => {}
let closed = false
// Why: initial listAll errors should return one RPC error, not a leaked
// subscription cleanup that later emits a stray end frame.
let initialized = false
const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*`
const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix
// Why: shared-control can carry multiple all-tab subscribers on one
// socket; include the RPC id so closing one does not evict siblings.
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
closed = true
unsubscribe()
if (initialized) {
emit({ type: 'end' })
}
},
connectionId
)
if (closed) {
return
}
const snapshots = await Promise.resolve(runtime.listAllMobileSessionTabs()).catch((error) => {
runtime.cleanupSubscription(subscriptionId)
throw error
})
if (closed) {
return
}
emit({ type: 'snapshots', snapshots })
initialized = true
if (closed) {
return
}
unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => {
emit({ type: 'updated', ...snapshot })
})
}
}),
defineMethod({
name: 'session.tabs.unsubscribeAll',
params: z
.object({
subscriptionId: z.string().min(1).optional()
})
.nullish(),
handler: async (params, { runtime, connectionId }) => {
const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*`
if (params?.subscriptionId) {
runtime.cleanupSubscription(`${cleanupPrefix}:${params.subscriptionId}`)
return { unsubscribed: true }
}
runtime.cleanupSubscription(cleanupPrefix)
runtime.cleanupSubscriptionsByPrefix(`${cleanupPrefix}:`)
return { unsubscribed: true }
}
}),
defineMethod({
name: 'markdown.readTab',
params: ActivateTab,
handler: async (params, { runtime }) =>
runtime.readMobileMarkdownTab(params.worktree, params.tabId)
}),
defineMethod({
name: 'markdown.saveTab',
params: SaveMarkdownTab,
handler: async (params, { runtime }) =>
runtime.saveMobileMarkdownTab(
params.worktree,
params.tabId,
params.baseVersion,
params.content
)
})
]