summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/utils
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/utils')
-rw-r--r--packages/app/src/utils/dom.ts51
-rw-r--r--packages/app/src/utils/id.ts99
-rw-r--r--packages/app/src/utils/index.ts1
-rw-r--r--packages/app/src/utils/persist.ts26
-rw-r--r--packages/app/src/utils/prompt.ts47
-rw-r--r--packages/app/src/utils/solid-dnd.tsx55
-rw-r--r--packages/app/src/utils/speech.ts302
7 files changed, 581 insertions, 0 deletions
diff --git a/packages/app/src/utils/dom.ts b/packages/app/src/utils/dom.ts
new file mode 100644
index 000000000..4f3724c7c
--- /dev/null
+++ b/packages/app/src/utils/dom.ts
@@ -0,0 +1,51 @@
+export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
+ const r = document.createRange()
+ r.selectNodeContents(lineElement)
+ r.setEnd(targetNode, offset)
+ return r.toString().length
+}
+
+export function getNodeOffsetInLine(lineElement: Element, charIndex: number): { node: Node; offset: number } | null {
+ const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null)
+ let remaining = Math.max(0, charIndex)
+ let lastText: Node | null = null
+ let lastLen = 0
+ let node: Node | null
+ while ((node = walker.nextNode())) {
+ const len = node.textContent?.length || 0
+ lastText = node
+ lastLen = len
+ if (remaining <= len) return { node, offset: remaining }
+ remaining -= len
+ }
+ if (lastText) return { node: lastText, offset: lastLen }
+ if (lineElement.firstChild) return { node: lineElement.firstChild, offset: 0 }
+ return null
+}
+
+export function getSelectionInContainer(
+ container: HTMLElement,
+): { sl: number; sch: number; el: number; ech: number } | null {
+ const s = window.getSelection()
+ if (!s || s.rangeCount === 0) return null
+ const r = s.getRangeAt(0)
+ const sc = r.startContainer
+ const ec = r.endContainer
+ const getLineElement = (n: Node) =>
+ (n.nodeType === Node.TEXT_NODE ? (n.parentElement as Element) : (n as Element))?.closest(".line")
+ const sle = getLineElement(sc)
+ const ele = getLineElement(ec)
+ if (!sle || !ele) return null
+ if (!container.contains(sle as Node) || !container.contains(ele as Node)) return null
+ const cc = container.querySelector("code") as HTMLElement | null
+ if (!cc) return null
+ const lines = Array.from(cc.querySelectorAll(".line"))
+ const sli = lines.indexOf(sle as Element)
+ const eli = lines.indexOf(ele as Element)
+ if (sli === -1 || eli === -1) return null
+ const sl = sli + 1
+ const el = eli + 1
+ const sch = getCharacterOffsetInLine(sle as Element, sc, r.startOffset)
+ const ech = getCharacterOffsetInLine(ele as Element, ec, r.endOffset)
+ return { sl, sch, el, ech }
+}
diff --git a/packages/app/src/utils/id.ts b/packages/app/src/utils/id.ts
new file mode 100644
index 000000000..fa27cf4c5
--- /dev/null
+++ b/packages/app/src/utils/id.ts
@@ -0,0 +1,99 @@
+import z from "zod"
+
+const prefixes = {
+ session: "ses",
+ message: "msg",
+ permission: "per",
+ user: "usr",
+ part: "prt",
+ pty: "pty",
+} as const
+
+const LENGTH = 26
+let lastTimestamp = 0
+let counter = 0
+
+type Prefix = keyof typeof prefixes
+export namespace Identifier {
+ export function schema(prefix: Prefix) {
+ return z.string().startsWith(prefixes[prefix])
+ }
+
+ export function ascending(prefix: Prefix, given?: string) {
+ return generateID(prefix, false, given)
+ }
+
+ export function descending(prefix: Prefix, given?: string) {
+ return generateID(prefix, true, given)
+ }
+}
+
+function generateID(prefix: Prefix, descending: boolean, given?: string): string {
+ if (!given) {
+ return create(prefix, descending)
+ }
+
+ if (!given.startsWith(prefixes[prefix])) {
+ throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
+ }
+
+ return given
+}
+
+function create(prefix: Prefix, descending: boolean, timestamp?: number): string {
+ const currentTimestamp = timestamp ?? Date.now()
+
+ if (currentTimestamp !== lastTimestamp) {
+ lastTimestamp = currentTimestamp
+ counter = 0
+ }
+
+ counter += 1
+
+ let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
+
+ if (descending) {
+ now = ~now
+ }
+
+ const timeBytes = new Uint8Array(6)
+ for (let i = 0; i < 6; i += 1) {
+ timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
+ }
+
+ return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12)
+}
+
+function bytesToHex(bytes: Uint8Array): string {
+ let hex = ""
+ for (let i = 0; i < bytes.length; i += 1) {
+ hex += bytes[i].toString(16).padStart(2, "0")
+ }
+ return hex
+}
+
+function randomBase62(length: number): string {
+ const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+ const bytes = getRandomBytes(length)
+ let result = ""
+ for (let i = 0; i < length; i += 1) {
+ result += chars[bytes[i] % 62]
+ }
+ return result
+}
+
+function getRandomBytes(length: number): Uint8Array {
+ const bytes = new Uint8Array(length)
+ const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined
+
+ if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
+ cryptoObj.getRandomValues(bytes)
+ return bytes
+ }
+
+ for (let i = 0; i < length; i += 1) {
+ bytes[i] = Math.floor(Math.random() * 256)
+ }
+
+ return bytes
+}
diff --git a/packages/app/src/utils/index.ts b/packages/app/src/utils/index.ts
new file mode 100644
index 000000000..d87053269
--- /dev/null
+++ b/packages/app/src/utils/index.ts
@@ -0,0 +1 @@
+export * from "./dom"
diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts
new file mode 100644
index 000000000..12b334f9f
--- /dev/null
+++ b/packages/app/src/utils/persist.ts
@@ -0,0 +1,26 @@
+import { usePlatform } from "@/context/platform"
+import { makePersisted } from "@solid-primitives/storage"
+import { createResource, type Accessor } from "solid-js"
+import type { SetStoreFunction, Store } from "solid-js/store"
+
+type InitType = Promise<string> | string | null
+type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
+
+export function persisted<T>(key: string, store: [Store<T>, SetStoreFunction<T>]): PersistedWithReady<T> {
+ const platform = usePlatform()
+ const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
+
+ // Create a resource that resolves when the store is initialized
+ // This integrates with Suspense and provides a ready signal
+ const isAsync = init instanceof Promise
+ const [ready] = createResource(
+ () => init,
+ async (initValue) => {
+ if (initValue instanceof Promise) await initValue
+ return true
+ },
+ { initialValue: !isAsync },
+ )
+
+ return [state, setState, init, () => ready() === true]
+}
diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts
new file mode 100644
index 000000000..45c5ce1f3
--- /dev/null
+++ b/packages/app/src/utils/prompt.ts
@@ -0,0 +1,47 @@
+import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2"
+import type { Prompt, FileAttachmentPart } from "@/context/prompt"
+
+/**
+ * Extract prompt content from message parts for restoring into the prompt input.
+ * This is used by undo to restore the original user prompt.
+ */
+export function extractPromptFromParts(parts: Part[]): Prompt {
+ const result: Prompt = []
+ let position = 0
+
+ for (const part of parts) {
+ if (part.type === "text") {
+ const textPart = part as TextPart
+ if (!textPart.synthetic && textPart.text) {
+ result.push({
+ type: "text",
+ content: textPart.text,
+ start: position,
+ end: position + textPart.text.length,
+ })
+ position += textPart.text.length
+ }
+ } else if (part.type === "file") {
+ const filePart = part as FilePart
+ if (filePart.source?.type === "file") {
+ const path = filePart.source.path
+ const content = "@" + path
+ const attachment: FileAttachmentPart = {
+ type: "file",
+ path,
+ content,
+ start: position,
+ end: position + content.length,
+ }
+ result.push(attachment)
+ position += content.length
+ }
+ }
+ }
+
+ if (result.length === 0) {
+ result.push({ type: "text", content: "", start: 0, end: 0 })
+ }
+
+ return result
+}
diff --git a/packages/app/src/utils/solid-dnd.tsx b/packages/app/src/utils/solid-dnd.tsx
new file mode 100644
index 000000000..a634be4b4
--- /dev/null
+++ b/packages/app/src/utils/solid-dnd.tsx
@@ -0,0 +1,55 @@
+import { useDragDropContext } from "@thisbeyond/solid-dnd"
+import { JSXElement } from "solid-js"
+import type { Transformer } from "@thisbeyond/solid-dnd"
+
+export const getDraggableId = (event: unknown): string | undefined => {
+ if (typeof event !== "object" || event === null) return undefined
+ if (!("draggable" in event)) return undefined
+ const draggable = (event as { draggable?: { id?: unknown } }).draggable
+ if (!draggable) return undefined
+ return typeof draggable.id === "string" ? draggable.id : undefined
+}
+
+export const ConstrainDragXAxis = (): JSXElement => {
+ const context = useDragDropContext()
+ if (!context) return <></>
+ const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
+ const transformer: Transformer = {
+ id: "constrain-x-axis",
+ order: 100,
+ callback: (transform) => ({ ...transform, x: 0 }),
+ }
+ onDragStart((event) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ addTransformer("draggables", id, transformer)
+ })
+ onDragEnd((event) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ removeTransformer("draggables", id, transformer.id)
+ })
+ return <></>
+}
+
+export const ConstrainDragYAxis = (): JSXElement => {
+ const context = useDragDropContext()
+ if (!context) return <></>
+ const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
+ const transformer: Transformer = {
+ id: "constrain-y-axis",
+ order: 100,
+ callback: (transform) => ({ ...transform, y: 0 }),
+ }
+ onDragStart((event) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ addTransformer("draggables", id, transformer)
+ })
+ onDragEnd((event) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ removeTransformer("draggables", id, transformer.id)
+ })
+ return <></>
+}
diff --git a/packages/app/src/utils/speech.ts b/packages/app/src/utils/speech.ts
new file mode 100644
index 000000000..921e0a159
--- /dev/null
+++ b/packages/app/src/utils/speech.ts
@@ -0,0 +1,302 @@
+import { createSignal, onCleanup } from "solid-js"
+
+// Minimal types to avoid relying on non-standard DOM typings
+type RecognitionResult = {
+ 0: { transcript: string }
+ isFinal: boolean
+}
+
+type RecognitionEvent = {
+ results: RecognitionResult[]
+ resultIndex: number
+}
+
+interface Recognition {
+ continuous: boolean
+ interimResults: boolean
+ lang: string
+ start: () => void
+ stop: () => void
+ onresult: ((e: RecognitionEvent) => void) | null
+ onerror: ((e: { error: string }) => void) | null
+ onend: (() => void) | null
+ onstart: (() => void) | null
+}
+
+const COMMIT_DELAY = 250
+
+const appendSegment = (base: string, addition: string) => {
+ const trimmed = addition.trim()
+ if (!trimmed) return base
+ if (!base) return trimmed
+ const needsSpace = /\S$/.test(base) && !/^[,.;!?]/.test(trimmed)
+ return `${base}${needsSpace ? " " : ""}${trimmed}`
+}
+
+const extractSuffix = (committed: string, hypothesis: string) => {
+ const cleanHypothesis = hypothesis.trim()
+ if (!cleanHypothesis) return ""
+ const baseTokens = committed.trim() ? committed.trim().split(/\s+/) : []
+ const hypothesisTokens = cleanHypothesis.split(/\s+/)
+ let index = 0
+ while (
+ index < baseTokens.length &&
+ index < hypothesisTokens.length &&
+ baseTokens[index] === hypothesisTokens[index]
+ ) {
+ index += 1
+ }
+ if (index < baseTokens.length) return ""
+ return hypothesisTokens.slice(index).join(" ")
+}
+
+export function createSpeechRecognition(opts?: {
+ lang?: string
+ onFinal?: (text: string) => void
+ onInterim?: (text: string) => void
+}) {
+ const hasSupport =
+ typeof window !== "undefined" &&
+ Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
+
+ const [isRecording, setIsRecording] = createSignal(false)
+ const [committed, setCommitted] = createSignal("")
+ const [interim, setInterim] = createSignal("")
+
+ let recognition: Recognition | undefined
+ let shouldContinue = false
+ let committedText = ""
+ let sessionCommitted = ""
+ let pendingHypothesis = ""
+ let lastInterimSuffix = ""
+ let shrinkCandidate: string | undefined
+ let commitTimer: number | undefined
+
+ const cancelPendingCommit = () => {
+ if (commitTimer === undefined) return
+ clearTimeout(commitTimer)
+ commitTimer = undefined
+ }
+
+ const commitSegment = (segment: string) => {
+ const nextCommitted = appendSegment(committedText, segment)
+ if (nextCommitted === committedText) return
+ committedText = nextCommitted
+ setCommitted(committedText)
+ if (opts?.onFinal) opts.onFinal(segment.trim())
+ }
+
+ const promotePending = () => {
+ if (!pendingHypothesis) return
+ const suffix = extractSuffix(sessionCommitted, pendingHypothesis)
+ if (!suffix) {
+ pendingHypothesis = ""
+ return
+ }
+ sessionCommitted = appendSegment(sessionCommitted, suffix)
+ commitSegment(suffix)
+ pendingHypothesis = ""
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ }
+
+ const applyInterim = (suffix: string, hypothesis: string) => {
+ cancelPendingCommit()
+ pendingHypothesis = hypothesis
+ lastInterimSuffix = suffix
+ shrinkCandidate = undefined
+ setInterim(suffix)
+ if (opts?.onInterim) {
+ opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
+ }
+ if (!suffix) return
+ const snapshot = hypothesis
+ commitTimer = window.setTimeout(() => {
+ if (pendingHypothesis !== snapshot) return
+ const currentSuffix = extractSuffix(sessionCommitted, pendingHypothesis)
+ if (!currentSuffix) return
+ sessionCommitted = appendSegment(sessionCommitted, currentSuffix)
+ commitSegment(currentSuffix)
+ pendingHypothesis = ""
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ }, COMMIT_DELAY)
+ }
+
+ if (hasSupport) {
+ const Ctor: new () => Recognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition
+
+ recognition = new Ctor()
+ recognition.continuous = false
+ recognition.interimResults = true
+ recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US")
+
+ recognition.onresult = (event: RecognitionEvent) => {
+ if (!event.results.length) return
+
+ let aggregatedFinal = ""
+ let latestHypothesis = ""
+
+ for (let i = 0; i < event.results.length; i += 1) {
+ const result = event.results[i]
+ const transcript = (result[0]?.transcript || "").trim()
+ if (!transcript) continue
+ if (result.isFinal) {
+ aggregatedFinal = appendSegment(aggregatedFinal, transcript)
+ } else {
+ latestHypothesis = transcript
+ }
+ }
+
+ if (aggregatedFinal) {
+ cancelPendingCommit()
+ const finalSuffix = extractSuffix(sessionCommitted, aggregatedFinal)
+ if (finalSuffix) {
+ sessionCommitted = appendSegment(sessionCommitted, finalSuffix)
+ commitSegment(finalSuffix)
+ }
+ pendingHypothesis = ""
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ return
+ }
+
+ cancelPendingCommit()
+
+ if (!latestHypothesis) {
+ shrinkCandidate = undefined
+ applyInterim("", "")
+ return
+ }
+
+ const suffix = extractSuffix(sessionCommitted, latestHypothesis)
+
+ if (!suffix) {
+ if (!lastInterimSuffix) {
+ shrinkCandidate = undefined
+ applyInterim("", latestHypothesis)
+ return
+ }
+ if (shrinkCandidate === "") {
+ applyInterim("", latestHypothesis)
+ return
+ }
+ shrinkCandidate = ""
+ pendingHypothesis = latestHypothesis
+ return
+ }
+
+ if (lastInterimSuffix && suffix.length < lastInterimSuffix.length) {
+ if (shrinkCandidate === suffix) {
+ applyInterim(suffix, latestHypothesis)
+ return
+ }
+ shrinkCandidate = suffix
+ pendingHypothesis = latestHypothesis
+ return
+ }
+
+ shrinkCandidate = undefined
+ applyInterim(suffix, latestHypothesis)
+ }
+
+ recognition.onerror = (e: { error: string }) => {
+ cancelPendingCommit()
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ if (e.error === "no-speech" && shouldContinue) {
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ setTimeout(() => {
+ try {
+ recognition?.start()
+ } catch {}
+ }, 150)
+ return
+ }
+ shouldContinue = false
+ setIsRecording(false)
+ }
+
+ recognition.onstart = () => {
+ sessionCommitted = ""
+ pendingHypothesis = ""
+ cancelPendingCommit()
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ setIsRecording(true)
+ }
+
+ recognition.onend = () => {
+ cancelPendingCommit()
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setIsRecording(false)
+ if (shouldContinue) {
+ setTimeout(() => {
+ try {
+ recognition?.start()
+ } catch {}
+ }, 150)
+ }
+ }
+ }
+
+ const start = () => {
+ if (!recognition) return
+ shouldContinue = true
+ sessionCommitted = ""
+ pendingHypothesis = ""
+ cancelPendingCommit()
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ try {
+ recognition.start()
+ } catch {}
+ }
+
+ const stop = () => {
+ if (!recognition) return
+ shouldContinue = false
+ promotePending()
+ cancelPendingCommit()
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ try {
+ recognition.stop()
+ } catch {}
+ }
+
+ onCleanup(() => {
+ shouldContinue = false
+ promotePending()
+ cancelPendingCommit()
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ try {
+ recognition?.stop()
+ } catch {}
+ })
+
+ return {
+ isSupported: () => hasSupport,
+ isRecording,
+ committed,
+ interim,
+ start,
+ stop,
+ }
+}