Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
// Secondary "session windows" — one extra OS window per chat so a user can
|
||||
// work with multiple chats side by side. The pure, Electron-free pieces live
|
||||
// here so they can be unit-tested with node --test (mirroring how the rest of
|
||||
// electron/*.ts splits testable logic out of the main.ts monolith).
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// Secondary windows open at the minimum usable size — a compact side panel for
|
||||
// subagent watch / cmd-click session pop-out, not a second full desktop.
|
||||
const SESSION_WINDOW_MIN_WIDTH = 420
|
||||
const SESSION_WINDOW_MIN_HEIGHT = 620
|
||||
|
||||
// Shared webPreferences for every window that renders the chat transcript — the
|
||||
// primary window AND the secondary session windows. Keeping it in one place is
|
||||
// the whole point: the two BrowserWindow definitions in main.ts used to be
|
||||
// hand-copied, and the secondary windows silently drifted apart (a streamed
|
||||
// answer stalled until the window regained focus because one of them lost the
|
||||
// throttling opt-out).
|
||||
//
|
||||
// Background throttling is deliberately NOT set here. It is managed at runtime
|
||||
// by main.ts (`setBackgroundThrottling` driven by the merged `hermes:active-work`
|
||||
// reports): while any turn is in flight every chat window is unthrottled so the
|
||||
// transcript's bounded timer flush keeps painting while blurred, occluded, or
|
||||
// minimized — and once all turns finish, Chromium's default throttling returns
|
||||
// so an idle hidden window costs ~nothing. A static `backgroundThrottling:
|
||||
// false` here would pin `document.visibilityState` to 'visible' forever,
|
||||
// turning every visibility-gated poll in the renderer into an always-on timer
|
||||
// (the "Hermes idles at 20% CPU while minimized" bug). The preload path is
|
||||
// injected because it depends on the Electron entry's __dirname.
|
||||
//
|
||||
// `autoplayPolicy: 'no-user-gesture-required'` is load-bearing for voice:
|
||||
// Chromium's default autoplay policy suspends audio (HTMLAudioElement.play()
|
||||
// and AudioContext) until the user has interacted with the frame. A voice
|
||||
// conversation started by the "Hey Hermes" wake word has NO preceding click,
|
||||
// so the FIRST reply's audio playback was rejected (NotAllowedError, silently
|
||||
// swallowed) and only turn 2+ spoke — the very "first message in a new voice
|
||||
// session is silent" bug. Manual voice-start worked only because the button
|
||||
// click counted as the gesture. This is a native app the user deliberately
|
||||
// launched; there is no drive-by-autoplay concern to protect against.
|
||||
//
|
||||
// `focusOnNavigation: false` keeps renderer-driven work passive. Electron's
|
||||
// default is true, so an in-page/SPA navigation can activate a blurred chat
|
||||
// window while its transcript is streaming. Explicit user actions still call
|
||||
// the main-process window focus paths (session re-open, notification/deep-link,
|
||||
// app activation), preserving intentional raises without background focus theft.
|
||||
function chatWindowWebPreferences(preloadPath: string) {
|
||||
return {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true,
|
||||
autoplayPolicy: 'no-user-gesture-required' as const,
|
||||
focusOnNavigation: false
|
||||
}
|
||||
}
|
||||
|
||||
// Build the renderer URL for a secondary window. The renderer uses a
|
||||
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
|
||||
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
||||
// treated as the route by HashRouter and would break routeSessionId(). The
|
||||
// renderer reads the flag from window.location.search to suppress the install /
|
||||
// onboarding overlays and the global session sidebar. `watch=1` marks a
|
||||
// spectator window (e.g. a running subagent's session): the renderer resumes it
|
||||
// lazily so the gateway never builds an agent just to stream into it.
|
||||
// `profile` names the backend the window must boot against (same carry as the
|
||||
// HUD's buildHudWindowUrl): without it a pop-out/watch window adopts the
|
||||
// PRIMARY profile and resolves the session id against the wrong backend
|
||||
// (#82768, #61286). Absent → unchanged primary adoption.
|
||||
function buildSessionWindowUrl(sessionId: string, { devServer, profile, rendererIndexPath, watch }: any = {}) {
|
||||
const profileKey = typeof profile === 'string' ? profile.trim() : ''
|
||||
const query = `?win=secondary${watch ? '&watch=1' : ''}${profileKey ? `&profile=${encodeURIComponent(profileKey)}` : ''}`
|
||||
const route = `#/${encodeURIComponent(sessionId)}`
|
||||
|
||||
if (devServer) {
|
||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||
|
||||
return `${base}/${query}${route}`
|
||||
}
|
||||
|
||||
return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}`
|
||||
}
|
||||
|
||||
// Full peer windows render the ordinary app shell, so they deliberately do
|
||||
// not use the `win` query parameter that selects a specialized renderer. The
|
||||
// separate marker lets the renderer distinguish a peer from the one primary
|
||||
// app window: app-launch source restoration belongs to the primary only, while
|
||||
// a peer keeps the already-running backend it joined during boot.
|
||||
function buildInstanceWindowUrl({ devServer, rendererIndexPath }: any = {}) {
|
||||
const query = '?peer=1'
|
||||
|
||||
if (devServer) {
|
||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||
|
||||
return `${base}/${query}`
|
||||
}
|
||||
|
||||
return `${pathToFileURL(rendererIndexPath).toString()}${query}`
|
||||
}
|
||||
|
||||
// Full "instance" windows (⌘⇧N / the "New Window" command) open a complete app
|
||||
// peer, not a compact chat. Cascade each one off its source window's bounds so a
|
||||
// new window doesn't land exactly on top of the one it was spawned from. Pure so
|
||||
// it's unit-testable; the Electron glue (reading the focused window's bounds,
|
||||
// constructing the BrowserWindow) stays in main.ts. `base` is the source
|
||||
// window's current bounds, or null when there's no live source window — then the
|
||||
// persisted primary geometry (`fallback`) is used as-is.
|
||||
const INSTANCE_CASCADE_OFFSET = 32
|
||||
|
||||
function instanceWindowBounds(base: { x: number; y: number; width: number; height: number } | null, fallback: any) {
|
||||
if (!base) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return {
|
||||
width: base.width,
|
||||
height: base.height,
|
||||
x: base.x + INSTANCE_CASCADE_OFFSET,
|
||||
y: base.y + INSTANCE_CASCADE_OFFSET
|
||||
}
|
||||
}
|
||||
|
||||
// A small registry keyed by sessionId that guarantees one window per chat:
|
||||
// opening a session that already has a live window focuses it instead of
|
||||
// spawning a duplicate, and a window removes itself from the registry when it
|
||||
// closes. The actual BrowserWindow construction is injected (the `factory`) so
|
||||
// this module stays free of Electron and is unit-testable.
|
||||
function createSessionWindowRegistry() {
|
||||
const windows = new Map()
|
||||
|
||||
function openOrFocus(sessionId, factory) {
|
||||
const key = typeof sessionId === 'string' ? sessionId.trim() : ''
|
||||
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const existing = windows.get(key)
|
||||
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
// Focus-or-create: never duplicate a window for the same chat.
|
||||
if (typeof existing.isMinimized === 'function' && existing.isMinimized()) {
|
||||
existing.restore?.()
|
||||
}
|
||||
|
||||
if (typeof existing.isVisible === 'function' && !existing.isVisible()) {
|
||||
existing.show?.()
|
||||
}
|
||||
|
||||
existing.focus?.()
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
const win = factory(key)
|
||||
|
||||
if (!win) {
|
||||
return null
|
||||
}
|
||||
|
||||
windows.set(key, win)
|
||||
|
||||
// Self-cleanup on close so the registry never holds a destroyed window.
|
||||
win.on?.('closed', () => {
|
||||
if (windows.get(key) === win) {
|
||||
windows.delete(key)
|
||||
}
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
return {
|
||||
openOrFocus,
|
||||
get: key => windows.get(key),
|
||||
has: key => windows.has(key),
|
||||
get size() {
|
||||
return windows.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
buildInstanceWindowUrl,
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
instanceWindowBounds,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
}
|
||||
Reference in New Issue
Block a user