mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai> Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
169 lines
6.1 KiB
TypeScript
169 lines
6.1 KiB
TypeScript
import { ipcMain, type IpcMainEvent, type WebContents } from 'electron'
|
|
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
|
|
import {
|
|
clearNativeChatTranscriptCache,
|
|
readNativeChatTranscriptCached
|
|
} from '../native-chat/transcript-read-cache'
|
|
import type { ReadTranscriptResult } from '../native-chat/transcript-reader'
|
|
import {
|
|
subscribeNativeChatTranscript,
|
|
type NativeChatTranscriptSubscription
|
|
} from '../native-chat/transcript-watch'
|
|
|
|
// Re-export so existing test imports of `clearNativeChatTranscriptCache` from
|
|
// this module keep working after the cache moved to transcript-read-cache.ts.
|
|
export { clearNativeChatTranscriptCache }
|
|
|
|
export type NativeChatReadSessionArgs = {
|
|
agent: AgentType
|
|
sessionId: string
|
|
/** How many of the most-recent turns to return. The renderer starts at the
|
|
* default window and raises this to page in older history as it scrolls up. */
|
|
limit?: number
|
|
/** Authoritative transcript path from the agent hook (providerSession), used to
|
|
* locate the file when the session id no longer names it (recent Claude Code). */
|
|
transcriptPath?: string
|
|
}
|
|
|
|
// Why: render only the most recent turns so switching to chat view on a long
|
|
// session (thousands of messages) doesn't stall on building that many message
|
|
// components. The full transcript is still cached; only the returned slice is
|
|
// capped. The renderer raises `limit` to page in older history; live appends
|
|
// extend it from there.
|
|
const DESKTOP_READ_WINDOW = 300
|
|
|
|
function windowTranscript(result: ReadTranscriptResult, limit: number): ReadTranscriptResult {
|
|
if (!('messages' in result) || result.messages.length <= limit) {
|
|
return result
|
|
}
|
|
return { ...result, messages: result.messages.slice(-limit) }
|
|
}
|
|
|
|
async function readSession(args: NativeChatReadSessionArgs): Promise<ReadTranscriptResult> {
|
|
const { agent, sessionId } = args
|
|
// Clamp to a positive window; default to the desktop window for the first page.
|
|
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW
|
|
// Desktop is full-class: window by count only, no char truncation.
|
|
const result = await readNativeChatTranscriptCached(agent, sessionId, args.transcriptPath)
|
|
return windowTranscript(result, limit)
|
|
}
|
|
|
|
export type NativeChatSubscribeArgs = {
|
|
/** Renderer-minted id, unique per webContents, echoed back on every emit so
|
|
* the renderer can route appends to the right hook instance. */
|
|
subscriptionId: string
|
|
agent: AgentType
|
|
sessionId: string
|
|
/** Authoritative transcript path from the agent hook (providerSession). */
|
|
transcriptPath?: string
|
|
}
|
|
|
|
export type NativeChatAppendedPayload = {
|
|
subscriptionId: string
|
|
messages: NativeChatMessage[]
|
|
}
|
|
|
|
type LiveSubscription = {
|
|
subscription: NativeChatTranscriptSubscription
|
|
}
|
|
|
|
// Why: live subscriptions are keyed by (webContents.id, subscriptionId) so the
|
|
// same renderer can watch several panes, and a destroyed window tears down all
|
|
// of its watchers — strict teardown to avoid fd leaks (plan U4 risk).
|
|
const liveSubscriptions = new Map<number, Map<string, LiveSubscription>>()
|
|
const senderCleanupRegistered = new Set<number>()
|
|
|
|
function teardownSubscription(senderId: number, subscriptionId: string): void {
|
|
const bySubId = liveSubscriptions.get(senderId)
|
|
const live = bySubId?.get(subscriptionId)
|
|
if (!live || !bySubId) {
|
|
return
|
|
}
|
|
live.subscription.unsubscribe()
|
|
bySubId.delete(subscriptionId)
|
|
if (bySubId.size === 0) {
|
|
liveSubscriptions.delete(senderId)
|
|
}
|
|
}
|
|
|
|
function teardownAllForSender(senderId: number): void {
|
|
const bySubId = liveSubscriptions.get(senderId)
|
|
if (!bySubId) {
|
|
return
|
|
}
|
|
for (const live of bySubId.values()) {
|
|
live.subscription.unsubscribe()
|
|
}
|
|
liveSubscriptions.delete(senderId)
|
|
senderCleanupRegistered.delete(senderId)
|
|
}
|
|
|
|
function registerSenderCleanup(sender: WebContents): void {
|
|
if (senderCleanupRegistered.has(sender.id)) {
|
|
return
|
|
}
|
|
senderCleanupRegistered.add(sender.id)
|
|
// Strict teardown: a closed/reloaded window releases every watcher it owns.
|
|
sender.once('destroyed', () => teardownAllForSender(sender.id))
|
|
}
|
|
|
|
async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise<void> {
|
|
const sender = event.sender
|
|
if (sender.isDestroyed()) {
|
|
return
|
|
}
|
|
const { subscriptionId, agent, sessionId, transcriptPath } = args
|
|
// Replace any prior subscription under the same id (session change/resubscribe).
|
|
teardownSubscription(sender.id, subscriptionId)
|
|
registerSenderCleanup(sender)
|
|
|
|
const subscription = await subscribeNativeChatTranscript({
|
|
agent,
|
|
sessionId,
|
|
transcriptPath,
|
|
onAppend: (messages) => {
|
|
if (sender.isDestroyed()) {
|
|
return
|
|
}
|
|
const payload: NativeChatAppendedPayload = { subscriptionId, messages }
|
|
sender.send('nativeChat:appended', payload)
|
|
}
|
|
})
|
|
|
|
// The window may have gone away (or the subscription been replaced) while we
|
|
// resolved the file path — don't register a now-orphaned watcher.
|
|
const stillCurrent = !sender.isDestroyed()
|
|
if (!stillCurrent) {
|
|
subscription.unsubscribe()
|
|
return
|
|
}
|
|
const bySubId = liveSubscriptions.get(sender.id) ?? new Map<string, LiveSubscription>()
|
|
// A concurrent subscribe with the same id beat us here; honor the latest.
|
|
const existing = bySubId.get(subscriptionId)
|
|
if (existing) {
|
|
existing.subscription.unsubscribe()
|
|
}
|
|
bySubId.set(subscriptionId, { subscription })
|
|
liveSubscriptions.set(sender.id, bySubId)
|
|
}
|
|
|
|
/** Test-only: drop all live transcript subscriptions between runs. */
|
|
export function clearNativeChatSubscriptions(): void {
|
|
const senderIds = Array.from(liveSubscriptions.keys())
|
|
for (const senderId of senderIds) {
|
|
teardownAllForSender(senderId)
|
|
}
|
|
}
|
|
|
|
export function registerNativeChatHandlers(): void {
|
|
ipcMain.handle('nativeChat:readSession', (_event, args: NativeChatReadSessionArgs) =>
|
|
readSession(args)
|
|
)
|
|
ipcMain.on('nativeChat:subscribe', (event, args: NativeChatSubscribeArgs) => {
|
|
void handleSubscribe(event, args)
|
|
})
|
|
ipcMain.on('nativeChat:unsubscribe', (event, args: { subscriptionId: string }) => {
|
|
teardownSubscription(event.sender.id, args.subscriptionId)
|
|
})
|
|
}
|