summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/components/prompt-input.tsx8
-rw-r--r--packages/app/src/components/terminal.tsx96
-rw-r--r--packages/app/src/context/file.tsx72
-rw-r--r--packages/app/src/context/global-sync.tsx47
-rw-r--r--packages/app/src/context/permission.tsx24
-rw-r--r--packages/app/src/pages/layout.tsx50
-rw-r--r--packages/app/src/utils/persist.ts72
-rw-r--r--packages/app/src/utils/speech.ts39
-rw-r--r--packages/app/src/utils/worktree.ts49
9 files changed, 375 insertions, 82 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 1bd7aa4eb..9f038b6e8 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -1557,13 +1557,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const timeoutMs = 5 * 60 * 1000
+ const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
- setTimeout(() => {
+ timer.id = window.setTimeout(() => {
resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
}, timeoutMs)
})
- const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout])
+ const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]).finally(() => {
+ if (timer.id === undefined) return
+ clearTimeout(timer.id)
+ })
pending.delete(session.id)
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index 022369afe..d38844802 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -67,6 +67,19 @@ export const Terminal = (props: TerminalProps) => {
let handleTextareaFocus: () => void
let handleTextareaBlur: () => void
let disposed = false
+ const cleanups: VoidFunction[] = []
+
+ const cleanup = () => {
+ if (!cleanups.length) return
+ const fns = cleanups.splice(0).reverse()
+ for (const fn of fns) {
+ try {
+ fn()
+ } catch {
+ // ignore
+ }
+ }
+ }
const getTerminalColors = (): TerminalColors => {
const mode = theme.mode()
@@ -128,7 +141,7 @@ export const Terminal = (props: TerminalProps) => {
if (disposed) return
const mod = loaded.mod
- ghostty = loaded.ghostty
+ const g = loaded.ghostty
const once = { value: false }
@@ -138,6 +151,13 @@ export const Terminal = (props: TerminalProps) => {
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
+ cleanups.push(() => {
+ if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
+ })
+ if (disposed) {
+ cleanup()
+ return
+ }
ws = socket
const t = new mod.Terminal({
@@ -148,8 +168,14 @@ export const Terminal = (props: TerminalProps) => {
allowTransparency: true,
theme: terminalColors(),
scrollback: 10_000,
- ghostty,
+ ghostty: g,
})
+ cleanups.push(() => t.dispose())
+ if (disposed) {
+ cleanup()
+ return
+ }
+ ghostty = g
term = t
const copy = () => {
@@ -201,13 +227,17 @@ export const Terminal = (props: TerminalProps) => {
return false
})
- fitAddon = new mod.FitAddon()
- serializeAddon = new SerializeAddon()
- t.loadAddon(serializeAddon)
- t.loadAddon(fitAddon)
+ const fit = new mod.FitAddon()
+ const serializer = new SerializeAddon()
+ cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.())
+ t.loadAddon(serializer)
+ t.loadAddon(fit)
+ fitAddon = fit
+ serializeAddon = serializer
t.open(container)
container.addEventListener("pointerdown", handlePointerDown)
+ cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
handleTextareaFocus = () => {
t.options.cursorBlink = true
@@ -218,6 +248,8 @@ export const Terminal = (props: TerminalProps) => {
t.textarea?.addEventListener("focus", handleTextareaFocus)
t.textarea?.addEventListener("blur", handleTextareaBlur)
+ cleanups.push(() => t.textarea?.removeEventListener("focus", handleTextareaFocus))
+ cleanups.push(() => t.textarea?.removeEventListener("blur", handleTextareaBlur))
focusTerminal()
@@ -233,10 +265,11 @@ export const Terminal = (props: TerminalProps) => {
})
}
- fitAddon.observeResize()
- handleResize = () => fitAddon.fit()
+ fit.observeResize()
+ handleResize = () => fit.fit()
window.addEventListener("resize", handleResize)
- t.onResize(async (size) => {
+ cleanups.push(() => window.removeEventListener("resize", handleResize))
+ const onResize = t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
await sdk.client.pty
.update({
@@ -249,20 +282,24 @@ export const Terminal = (props: TerminalProps) => {
.catch(() => {})
}
})
- t.onData((data) => {
+ cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.())
+ const onData = t.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
}
})
- t.onKey((key) => {
+ cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.())
+ const onKey = t.onKey((key) => {
if (key.key == "Enter") {
props.onSubmit?.()
}
})
+ cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.())
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
- socket.addEventListener("open", () => {
+
+ const handleOpen = () => {
local.onConnect?.()
sdk.client.pty
.update({
@@ -273,18 +310,27 @@ export const Terminal = (props: TerminalProps) => {
},
})
.catch(() => {})
- })
- socket.addEventListener("message", (event) => {
+ }
+ socket.addEventListener("open", handleOpen)
+ cleanups.push(() => socket.removeEventListener("open", handleOpen))
+
+ const handleMessage = (event: MessageEvent) => {
t.write(event.data)
- })
- socket.addEventListener("error", (error) => {
+ }
+ socket.addEventListener("message", handleMessage)
+ cleanups.push(() => socket.removeEventListener("message", handleMessage))
+
+ const handleError = (error: Event) => {
if (disposed) return
if (once.value) return
once.value = true
console.error("WebSocket error:", error)
local.onConnectError?.(error)
- })
- socket.addEventListener("close", (event) => {
+ }
+ socket.addEventListener("error", handleError)
+ cleanups.push(() => socket.removeEventListener("error", handleError))
+
+ const handleClose = (event: CloseEvent) => {
if (disposed) return
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
// For other codes (network issues, server restart), trigger error handler
@@ -293,7 +339,9 @@ export const Terminal = (props: TerminalProps) => {
once.value = true
local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
}
- })
+ }
+ socket.addEventListener("close", handleClose)
+ cleanups.push(() => socket.removeEventListener("close", handleClose))
}
void run().catch((err) => {
@@ -309,13 +357,6 @@ export const Terminal = (props: TerminalProps) => {
onCleanup(() => {
disposed = true
- if (handleResize) {
- window.removeEventListener("resize", handleResize)
- }
- container.removeEventListener("pointerdown", handlePointerDown)
- term?.textarea?.removeEventListener("focus", handleTextareaFocus)
- term?.textarea?.removeEventListener("blur", handleTextareaBlur)
-
const t = term
if (serializeAddon && props.onCleanup && t) {
const buffer = (() => {
@@ -334,8 +375,7 @@ export const Terminal = (props: TerminalProps) => {
})
}
- ws?.close()
- t?.dispose()
+ cleanup()
})
return (
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx
index 16deacfe8..7509334ed 100644
--- a/packages/app/src/context/file.tsx
+++ b/packages/app/src/context/file.tsx
@@ -151,6 +151,28 @@ const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
+const MAX_FILE_CONTENT_ENTRIES = 40
+const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
+
+const contentLru = new Map<string, number>()
+
+function approxBytes(content: FileContent) {
+ const patchBytes =
+ content.patch?.hunks.reduce((total, hunk) => {
+ return total + hunk.lines.reduce((sum, line) => sum + line.length, 0)
+ }, 0) ?? 0
+
+ return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
+}
+
+function touchContent(path: string, bytes?: number) {
+ const prev = contentLru.get(path)
+ if (prev === undefined && bytes === undefined) return
+ const value = bytes ?? prev ?? 0
+ contentLru.delete(path)
+ contentLru.set(path, value)
+}
+
type ViewSession = ReturnType<typeof createViewSession>
type ViewCacheEntry = {
@@ -315,10 +337,40 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
dir: { "": { expanded: true } },
})
+ const evictContent = (keep?: Set<string>) => {
+ const protectedSet = keep ?? new Set<string>()
+ const total = () => {
+ return Array.from(contentLru.values()).reduce((sum, bytes) => sum + bytes, 0)
+ }
+
+ while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || total() > MAX_FILE_CONTENT_BYTES) {
+ const path = contentLru.keys().next().value
+ if (!path) return
+
+ if (protectedSet.has(path)) {
+ touchContent(path)
+ if (contentLru.size <= protectedSet.size) return
+ continue
+ }
+
+ contentLru.delete(path)
+ if (!store.file[path]) continue
+ setStore(
+ "file",
+ path,
+ produce((draft) => {
+ draft.content = undefined
+ draft.loaded = false
+ }),
+ )
+ }
+ }
+
createEffect(() => {
scope()
inflight.clear()
treeInflight.clear()
+ contentLru.clear()
setStore("file", {})
setTree("node", {})
setTree("dir", { "": { expanded: true } })
@@ -399,15 +451,20 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
.read({ path })
.then((x) => {
if (scope() !== directory) return
+ const content = x.data
setStore(
"file",
path,
produce((draft) => {
draft.loaded = true
draft.loading = false
- draft.content = x.data
+ draft.content = content
}),
)
+
+ if (!content) return
+ touchContent(path, approxBytes(content))
+ evictContent(new Set([path]))
})
.catch((e) => {
if (scope() !== directory) return
@@ -597,7 +654,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
listDir(parent, { force: true })
})
- const get = (input: string) => store.file[normalize(input)]
+ const get = (input: string) => {
+ const path = normalize(input)
+ const file = store.file[path]
+ const content = file?.content
+ if (!content) return file
+ if (contentLru.has(path)) {
+ touchContent(path)
+ return file
+ }
+ touchContent(path, approxBytes(content))
+ return file
+ }
const scrollTop = (input: string) => view().scrollTop(normalize(input))
const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index 4efbf62aa..fb67193ab 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -546,6 +546,37 @@ function createGlobalSync() {
return promise
}
+ function purgeMessageParts(setStore: SetStoreFunction<State>, messageID: string | undefined) {
+ if (!messageID) return
+ setStore(
+ produce((draft) => {
+ delete draft.part[messageID]
+ }),
+ )
+ }
+
+ function purgeSessionData(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string | undefined) {
+ if (!sessionID) return
+
+ const messages = store.message[sessionID]
+ const messageIDs = (messages ?? []).map((m) => m.id).filter((id): id is string => !!id)
+
+ setStore(
+ produce((draft) => {
+ delete draft.message[sessionID]
+ delete draft.session_diff[sessionID]
+ delete draft.todo[sessionID]
+ delete draft.permission[sessionID]
+ delete draft.question[sessionID]
+ delete draft.session_status[sessionID]
+
+ for (const messageID of messageIDs) {
+ delete draft.part[messageID]
+ }
+ }),
+ )
+ }
+
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
@@ -651,9 +682,7 @@ function createGlobalSync() {
}),
)
}
-
cleanupSessionCaches(info.id)
-
if (info.parentID) break
setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -679,9 +708,7 @@ function createGlobalSync() {
}),
)
}
-
cleanupSessionCaches(sessionID)
-
if (event.properties.info.parentID) break
setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -757,15 +784,19 @@ function createGlobalSync() {
break
}
case "message.part.removed": {
- const parts = store.part[event.properties.messageID]
+ const messageID = event.properties.messageID
+ const parts = store.part[messageID]
if (!parts) break
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
if (result.found) {
setStore(
- "part",
- event.properties.messageID,
produce((draft) => {
- draft.splice(result.index, 1)
+ const list = draft.part[messageID]
+ if (!list) return
+ const next = Binary.search(list, event.properties.partID, (p) => p.id)
+ if (!next.found) return
+ list.splice(next.index, 1)
+ if (list.length === 0) delete draft.part[messageID]
}),
)
}
diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx
index d85f2ef24..a701dbd1f 100644
--- a/packages/app/src/context/permission.tsx
+++ b/packages/app/src/context/permission.tsx
@@ -67,7 +67,21 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
- const responded = new Set<string>()
+ const MAX_RESPONDED = 1000
+ const RESPONDED_TTL_MS = 60 * 60 * 1000
+ const responded = new Map<string, number>()
+
+ function pruneResponded(now: number) {
+ for (const [id, ts] of responded) {
+ if (now - ts < RESPONDED_TTL_MS) break
+ responded.delete(id)
+ }
+
+ for (const id of responded.keys()) {
+ if (responded.size <= MAX_RESPONDED) break
+ responded.delete(id)
+ }
+ }
const respond: PermissionRespondFn = (input) => {
globalSDK.client.permission.respond(input).catch(() => {
@@ -76,8 +90,12 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function respondOnce(permission: PermissionRequest, directory?: string) {
- if (responded.has(permission.id)) return
- responded.add(permission.id)
+ const now = Date.now()
+ const hit = responded.has(permission.id)
+ responded.delete(permission.id)
+ responded.set(permission.id, now)
+ pruneResponded(now)
+ if (hit) return
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 82a3fa6c9..1328b96be 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -685,12 +685,34 @@ export default function Layout(props: ParentProps) {
running: number
}
- const prefetchChunk = 600
+ const prefetchChunk = 200
const prefetchConcurrency = 1
const prefetchPendingLimit = 6
const prefetchToken = { value: 0 }
const prefetchQueues = new Map<string, PrefetchQueue>()
+ const PREFETCH_MAX_SESSIONS_PER_DIR = 10
+ const prefetchedByDir = new Map<string, Map<string, true>>()
+
+ const lruFor = (directory: string) => {
+ const existing = prefetchedByDir.get(directory)
+ if (existing) return existing
+ const created = new Map<string, true>()
+ prefetchedByDir.set(directory, created)
+ return created
+ }
+
+ const markPrefetched = (directory: string, sessionID: string) => {
+ const lru = lruFor(directory)
+ if (lru.has(sessionID)) lru.delete(sessionID)
+ lru.set(sessionID, true)
+ while (lru.size > PREFETCH_MAX_SESSIONS_PER_DIR) {
+ const oldest = lru.keys().next().value as string | undefined
+ if (!oldest) return
+ lru.delete(oldest)
+ }
+ }
+
createEffect(() => {
params.dir
globalSDK.url
@@ -783,6 +805,11 @@ export default function Layout(props: ParentProps) {
if (q.inflight.has(session.id)) return
if (q.pendingSet.has(session.id)) return
+ const lru = lruFor(directory)
+ const known = lru.has(session.id)
+ if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
+ markPrefetched(directory, session.id)
+
if (priority === "high") q.pending.unshift(session.id)
if (priority !== "high") q.pending.push(session.id)
q.pendingSet.add(session.id)
@@ -1669,6 +1696,22 @@ export default function Layout(props: ParentProps) {
pendingRename: false,
})
+ const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
+ const cancelHoverPrefetch = () => {
+ if (hoverPrefetch.current === undefined) return
+ clearTimeout(hoverPrefetch.current)
+ hoverPrefetch.current = undefined
+ }
+ const scheduleHoverPrefetch = () => {
+ if (hoverPrefetch.current !== undefined) return
+ hoverPrefetch.current = setTimeout(() => {
+ hoverPrefetch.current = undefined
+ prefetchSession(props.session)
+ }, 200)
+ }
+
+ onCleanup(cancelHoverPrefetch)
+
const messageLabel = (message: Message) => {
const parts = sessionStore.part[message.id] ?? []
const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
@@ -1679,7 +1722,10 @@ export default function Layout(props: ParentProps) {
<A
href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menu.open ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
- onMouseEnter={() => prefetchSession(props.session, "high")}
+ onPointerEnter={scheduleHoverPrefetch}
+ onPointerLeave={cancelHoverPrefetch}
+ onMouseEnter={scheduleHoverPrefetch}
+ onMouseLeave={cancelHoverPrefetch}
onFocus={() => prefetchSession(props.session, "high")}
onClick={() => {
setState("hoverSession", undefined)
diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts
index 129695f86..0ca3abad0 100644
--- a/packages/app/src/utils/persist.ts
+++ b/packages/app/src/utils/persist.ts
@@ -18,7 +18,52 @@ const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat"
const LOCAL_PREFIX = "opencode."
const fallback = { disabled: false }
-const cache = new Map<string, string>()
+
+const CACHE_MAX_ENTRIES = 500
+const CACHE_MAX_BYTES = 8 * 1024 * 1024
+
+type CacheEntry = { value: string; bytes: number }
+const cache = new Map<string, CacheEntry>()
+const cacheTotal = { bytes: 0 }
+
+function cacheDelete(key: string) {
+ const entry = cache.get(key)
+ if (!entry) return
+ cacheTotal.bytes -= entry.bytes
+ cache.delete(key)
+}
+
+function cachePrune() {
+ for (;;) {
+ if (cache.size <= CACHE_MAX_ENTRIES && cacheTotal.bytes <= CACHE_MAX_BYTES) return
+ const oldest = cache.keys().next().value as string | undefined
+ if (!oldest) return
+ cacheDelete(oldest)
+ }
+}
+
+function cacheSet(key: string, value: string) {
+ const bytes = value.length * 2
+ if (bytes > CACHE_MAX_BYTES) {
+ cacheDelete(key)
+ return
+ }
+
+ const entry = cache.get(key)
+ if (entry) cacheTotal.bytes -= entry.bytes
+ cache.delete(key)
+ cache.set(key, { value, bytes })
+ cacheTotal.bytes += bytes
+ cachePrune()
+}
+
+function cacheGet(key: string) {
+ const entry = cache.get(key)
+ if (!entry) return
+ cache.delete(key)
+ cache.set(key, entry)
+ return entry.value
+}
function quota(error: unknown) {
if (error instanceof DOMException) {
@@ -63,9 +108,11 @@ function evict(storage: Storage, keep: string, value: string) {
for (const item of items) {
storage.removeItem(item.key)
+ cacheDelete(item.key)
try {
storage.setItem(keep, value)
+ cacheSet(keep, value)
return true
} catch (error) {
if (!quota(error)) throw error
@@ -78,6 +125,7 @@ function evict(storage: Storage, keep: string, value: string) {
function write(storage: Storage, key: string, value: string) {
try {
storage.setItem(key, value)
+ cacheSet(key, value)
return true
} catch (error) {
if (!quota(error)) throw error
@@ -85,13 +133,17 @@ function write(storage: Storage, key: string, value: string) {
try {
storage.removeItem(key)
+ cacheDelete(key)
storage.setItem(key, value)
+ cacheSet(key, value)
return true
} catch (error) {
if (!quota(error)) throw error
}
- return evict(storage, key, value)
+ const ok = evict(storage, key, value)
+ if (!ok) cacheSet(key, value)
+ return ok
}
function snapshot(value: unknown) {
@@ -148,7 +200,7 @@ function localStorageWithPrefix(prefix: string): SyncStorage {
return {
getItem: (key) => {
const name = item(key)
- const cached = cache.get(name)
+ const cached = cacheGet(name)
if (fallback.disabled && cached !== undefined) return cached
const stored = (() => {
@@ -160,12 +212,12 @@ function localStorageWithPrefix(prefix: string): SyncStorage {
}
})()
if (stored === null) return cached ?? null
- cache.set(name, stored)
+ cacheSet(name, stored)
return stored
},
setItem: (key, value) => {
const name = item(key)
- cache.set(name, value)
+ cacheSet(name, value)
if (fallback.disabled) return
try {
if (write(localStorage, name, value)) return
@@ -177,7 +229,7 @@ function localStorageWithPrefix(prefix: string): SyncStorage {
},
removeItem: (key) => {
const name = item(key)
- cache.delete(name)
+ cacheDelete(name)
if (fallback.disabled) return
try {
localStorage.removeItem(name)
@@ -191,7 +243,7 @@ function localStorageWithPrefix(prefix: string): SyncStorage {
function localStorageDirect(): SyncStorage {
return {
getItem: (key) => {
- const cached = cache.get(key)
+ const cached = cacheGet(key)
if (fallback.disabled && cached !== undefined) return cached
const stored = (() => {
@@ -203,11 +255,11 @@ function localStorageDirect(): SyncStorage {
}
})()
if (stored === null) return cached ?? null
- cache.set(key, stored)
+ cacheSet(key, stored)
return stored
},
setItem: (key, value) => {
- cache.set(key, value)
+ cacheSet(key, value)
if (fallback.disabled) return
try {
if (write(localStorage, key, value)) return
@@ -218,7 +270,7 @@ function localStorageDirect(): SyncStorage {
fallback.disabled = true
},
removeItem: (key) => {
- cache.delete(key)
+ cacheDelete(key)
if (fallback.disabled) return
try {
localStorage.removeItem(key)
diff --git a/packages/app/src/utils/speech.ts b/packages/app/src/utils/speech.ts
index c8acf5241..201c1261b 100644
--- a/packages/app/src/utils/speech.ts
+++ b/packages/app/src/utils/speech.ts
@@ -78,6 +78,7 @@ export function createSpeechRecognition(opts?: {
let lastInterimSuffix = ""
let shrinkCandidate: string | undefined
let commitTimer: number | undefined
+ let restartTimer: number | undefined
const cancelPendingCommit = () => {
if (commitTimer === undefined) return
@@ -85,6 +86,26 @@ export function createSpeechRecognition(opts?: {
commitTimer = undefined
}
+ const clearRestart = () => {
+ if (restartTimer === undefined) return
+ window.clearTimeout(restartTimer)
+ restartTimer = undefined
+ }
+
+ const scheduleRestart = () => {
+ clearRestart()
+ if (!shouldContinue) return
+ if (!recognition) return
+ restartTimer = window.setTimeout(() => {
+ restartTimer = undefined
+ if (!shouldContinue) return
+ if (!recognition) return
+ try {
+ recognition.start()
+ } catch {}
+ }, 150)
+ }
+
const commitSegment = (segment: string) => {
const nextCommitted = appendSegment(committedText, segment)
if (nextCommitted === committedText) return
@@ -214,17 +235,14 @@ export function createSpeechRecognition(opts?: {
}
recognition.onerror = (e: { error: string }) => {
+ clearRestart()
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
if (e.error === "no-speech" && shouldContinue) {
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
- setTimeout(() => {
- try {
- recognition?.start()
- } catch {}
- }, 150)
+ scheduleRestart()
return
}
shouldContinue = false
@@ -232,6 +250,7 @@ export function createSpeechRecognition(opts?: {
}
recognition.onstart = () => {
+ clearRestart()
sessionCommitted = ""
pendingHypothesis = ""
cancelPendingCommit()
@@ -243,22 +262,20 @@ export function createSpeechRecognition(opts?: {
}
recognition.onend = () => {
+ clearRestart()
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("isRecording", false)
if (shouldContinue) {
- setTimeout(() => {
- try {
- recognition?.start()
- } catch {}
- }, 150)
+ scheduleRestart()
}
}
}
const start = () => {
if (!recognition) return
+ clearRestart()
shouldContinue = true
sessionCommitted = ""
pendingHypothesis = ""
@@ -274,6 +291,7 @@ export function createSpeechRecognition(opts?: {
const stop = () => {
if (!recognition) return
shouldContinue = false
+ clearRestart()
promotePending()
cancelPendingCommit()
lastInterimSuffix = ""
@@ -287,6 +305,7 @@ export function createSpeechRecognition(opts?: {
onCleanup(() => {
shouldContinue = false
+ clearRestart()
promotePending()
cancelPendingCommit()
lastInterimSuffix = ""
diff --git a/packages/app/src/utils/worktree.ts b/packages/app/src/utils/worktree.ts
index 7c0055920..581afd553 100644
--- a/packages/app/src/utils/worktree.ts
+++ b/packages/app/src/utils/worktree.ts
@@ -13,7 +13,21 @@ type State =
}
const state = new Map<string, State>()
-const waiters = new Map<string, Array<(state: State) => void>>()
+const waiters = new Map<
+ string,
+ {
+ promise: Promise<State>
+ resolve: (state: State) => void
+ }
+>()
+
+function deferred() {
+ const box = { resolve: (_: State) => {} }
+ const promise = new Promise<State>((resolve) => {
+ box.resolve = resolve
+ })
+ return { promise, resolve: box.resolve }
+}
export const Worktree = {
get(directory: string) {
@@ -27,32 +41,33 @@ export const Worktree = {
},
ready(directory: string) {
const key = normalize(directory)
- state.set(key, { status: "ready" })
- const list = waiters.get(key)
- if (!list) return
+ const next = { status: "ready" } as const
+ state.set(key, next)
+ const waiter = waiters.get(key)
+ if (!waiter) return
waiters.delete(key)
- for (const fn of list) fn({ status: "ready" })
+ waiter.resolve(next)
},
failed(directory: string, message: string) {
const key = normalize(directory)
- state.set(key, { status: "failed", message })
- const list = waiters.get(key)
- if (!list) return
+ const next = { status: "failed", message } as const
+ state.set(key, next)
+ const waiter = waiters.get(key)
+ if (!waiter) return
waiters.delete(key)
- for (const fn of list) fn({ status: "failed", message })
+ waiter.resolve(next)
},
wait(directory: string) {
const key = normalize(directory)
const current = state.get(key)
if (current && current.status !== "pending") return Promise.resolve(current)
- return new Promise<State>((resolve) => {
- const list = waiters.get(key)
- if (!list) {
- waiters.set(key, [resolve])
- return
- }
- list.push(resolve)
- })
+ const existing = waiters.get(key)
+ if (existing) return existing.promise
+
+ const waiter = deferred()
+
+ waiters.set(key, waiter)
+ return waiter.promise
},
}