فهرست منبع

chore: cleanup (#17115)

Adam 5 ماه پیش
والد
کامیت
d4107d51f1
35فایلهای تغییر یافته به همراه75 افزوده شده و 1202 حذف شده
  1. 3 15
      packages/app/src/components/session/session-sortable-terminal-tab.tsx
  2. 0 16
      packages/app/src/components/settings-agents.tsx
  3. 0 16
      packages/app/src/components/settings-commands.tsx
  4. 0 16
      packages/app/src/components/settings-mcp.tsx
  5. 0 230
      packages/app/src/components/settings-permissions.tsx
  6. 2 6
      packages/app/src/context/global-sync.test.ts
  7. 0 3
      packages/app/src/context/global-sync.tsx
  8. 0 66
      packages/app/src/context/notification-index.ts
  9. 0 73
      packages/app/src/context/notification.test.ts
  10. 2 4
      packages/app/src/pages/layout.tsx
  11. 8 8
      packages/app/src/pages/layout/helpers.test.ts
  12. 0 8
      packages/app/src/pages/layout/helpers.ts
  13. 0 63
      packages/app/src/pages/layout/sidebar-project-helpers.test.ts
  14. 0 11
      packages/app/src/pages/layout/sidebar-project-helpers.ts
  15. 6 12
      packages/app/src/pages/layout/sidebar-project.tsx
  16. 0 1
      packages/app/src/pages/layout/sidebar-shell-helpers.ts
  17. 0 13
      packages/app/src/pages/layout/sidebar-shell.test.ts
  18. 1 2
      packages/app/src/pages/layout/sidebar-shell.tsx
  19. 0 2
      packages/app/src/pages/layout/sidebar-workspace-helpers.ts
  20. 0 13
      packages/app/src/pages/layout/sidebar-workspace.test.ts
  21. 30 70
      packages/app/src/pages/layout/sidebar-workspace.tsx
  22. 1 2
      packages/app/src/pages/session/composer/index.ts
  23. 0 10
      packages/app/src/pages/session/composer/session-composer-helpers.ts
  24. 1 1
      packages/app/src/pages/session/composer/session-composer-state.test.ts
  25. 11 20
      packages/app/src/pages/session/composer/session-composer-state.ts
  26. 1 55
      packages/app/src/pages/session/helpers.ts
  27. 0 10
      packages/app/src/pages/session/session-command-helpers.ts
  28. 0 22
      packages/app/src/pages/session/session-prompt-dock.test.ts
  29. 0 4
      packages/app/src/pages/session/session-prompt-helpers.ts
  30. 0 44
      packages/app/src/pages/session/use-session-commands.test.ts
  31. 9 6
      packages/app/src/pages/session/use-session-commands.tsx
  32. 0 2
      packages/app/src/pages/session/use-session-hash-scroll.ts
  33. 0 51
      packages/app/src/utils/dom.ts
  34. 0 1
      packages/app/src/utils/index.ts
  35. 0 326
      packages/app/src/utils/speech.ts

+ 3 - 15
packages/app/src/components/session/session-sortable-terminal-tab.tsx

@@ -8,6 +8,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Icon } from "@opencode-ai/ui/icon"
 import { useTerminal, type LocalPTY } from "@/context/terminal"
 import { useLanguage } from "@/context/language"
+import { focusTerminalById } from "@/pages/session/helpers"
 
 export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
   const terminal = useTerminal()
@@ -53,21 +54,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
 
   const focus = () => {
     if (store.editing) return
-
-    if (document.activeElement instanceof HTMLElement) {
-      document.activeElement.blur()
-    }
-    const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`)
-    const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
-    if (!element) return
-
-    const textarea = element.querySelector("textarea") as HTMLTextAreaElement
-    if (textarea) {
-      textarea.focus()
-      return
-    }
-    element.focus()
-    element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
+    if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
+    focusTerminalById(props.terminal.id)
   }
 
   const edit = (e?: Event) => {

+ 0 - 16
packages/app/src/components/settings-agents.tsx

@@ -1,16 +0,0 @@
-import { Component } from "solid-js"
-import { useLanguage } from "@/context/language"
-
-export const SettingsAgents: Component = () => {
-  // TODO: Replace this placeholder with full agents settings controls.
-  const language = useLanguage()
-
-  return (
-    <div class="flex flex-col h-full overflow-y-auto">
-      <div class="flex flex-col gap-6 p-6 max-w-[600px]">
-        <h2 class="text-16-medium text-text-strong">{language.t("settings.agents.title")}</h2>
-        <p class="text-14-regular text-text-weak">{language.t("settings.agents.description")}</p>
-      </div>
-    </div>
-  )
-}

+ 0 - 16
packages/app/src/components/settings-commands.tsx

@@ -1,16 +0,0 @@
-import { Component } from "solid-js"
-import { useLanguage } from "@/context/language"
-
-export const SettingsCommands: Component = () => {
-  // TODO: Replace this placeholder with full commands settings controls.
-  const language = useLanguage()
-
-  return (
-    <div class="flex flex-col h-full overflow-y-auto">
-      <div class="flex flex-col gap-6 p-6 max-w-[600px]">
-        <h2 class="text-16-medium text-text-strong">{language.t("settings.commands.title")}</h2>
-        <p class="text-14-regular text-text-weak">{language.t("settings.commands.description")}</p>
-      </div>
-    </div>
-  )
-}

+ 0 - 16
packages/app/src/components/settings-mcp.tsx

@@ -1,16 +0,0 @@
-import { Component } from "solid-js"
-import { useLanguage } from "@/context/language"
-
-export const SettingsMcp: Component = () => {
-  // TODO: Replace this placeholder with full MCP settings controls.
-  const language = useLanguage()
-
-  return (
-    <div class="flex flex-col h-full overflow-y-auto">
-      <div class="flex flex-col gap-6 p-6 max-w-[600px]">
-        <h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
-        <p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
-      </div>
-    </div>
-  )
-}

+ 0 - 230
packages/app/src/components/settings-permissions.tsx

@@ -1,230 +0,0 @@
-import { Select } from "@opencode-ai/ui/select"
-import { showToast } from "@opencode-ai/ui/toast"
-import { Component, For, createMemo, type JSX } from "solid-js"
-import { useGlobalSync } from "@/context/global-sync"
-import { useLanguage } from "@/context/language"
-
-type PermissionAction = "allow" | "ask" | "deny"
-
-type PermissionObject = Record<string, PermissionAction>
-type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
-type PermissionMap = Record<string, PermissionValue>
-
-type PermissionItem = {
-  id: string
-  title: string
-  description: string
-}
-
-const ACTIONS = [
-  { value: "allow", label: "settings.permissions.action.allow" },
-  { value: "ask", label: "settings.permissions.action.ask" },
-  { value: "deny", label: "settings.permissions.action.deny" },
-] as const
-
-const ITEMS = [
-  {
-    id: "read",
-    title: "settings.permissions.tool.read.title",
-    description: "settings.permissions.tool.read.description",
-  },
-  {
-    id: "edit",
-    title: "settings.permissions.tool.edit.title",
-    description: "settings.permissions.tool.edit.description",
-  },
-  {
-    id: "glob",
-    title: "settings.permissions.tool.glob.title",
-    description: "settings.permissions.tool.glob.description",
-  },
-  {
-    id: "grep",
-    title: "settings.permissions.tool.grep.title",
-    description: "settings.permissions.tool.grep.description",
-  },
-  {
-    id: "list",
-    title: "settings.permissions.tool.list.title",
-    description: "settings.permissions.tool.list.description",
-  },
-  {
-    id: "bash",
-    title: "settings.permissions.tool.bash.title",
-    description: "settings.permissions.tool.bash.description",
-  },
-  {
-    id: "task",
-    title: "settings.permissions.tool.task.title",
-    description: "settings.permissions.tool.task.description",
-  },
-  {
-    id: "skill",
-    title: "settings.permissions.tool.skill.title",
-    description: "settings.permissions.tool.skill.description",
-  },
-  {
-    id: "lsp",
-    title: "settings.permissions.tool.lsp.title",
-    description: "settings.permissions.tool.lsp.description",
-  },
-  {
-    id: "todoread",
-    title: "settings.permissions.tool.todoread.title",
-    description: "settings.permissions.tool.todoread.description",
-  },
-  {
-    id: "todowrite",
-    title: "settings.permissions.tool.todowrite.title",
-    description: "settings.permissions.tool.todowrite.description",
-  },
-  {
-    id: "webfetch",
-    title: "settings.permissions.tool.webfetch.title",
-    description: "settings.permissions.tool.webfetch.description",
-  },
-  {
-    id: "websearch",
-    title: "settings.permissions.tool.websearch.title",
-    description: "settings.permissions.tool.websearch.description",
-  },
-  {
-    id: "codesearch",
-    title: "settings.permissions.tool.codesearch.title",
-    description: "settings.permissions.tool.codesearch.description",
-  },
-  {
-    id: "external_directory",
-    title: "settings.permissions.tool.external_directory.title",
-    description: "settings.permissions.tool.external_directory.description",
-  },
-  {
-    id: "doom_loop",
-    title: "settings.permissions.tool.doom_loop.title",
-    description: "settings.permissions.tool.doom_loop.description",
-  },
-] as const
-
-const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
-
-function toMap(value: unknown): PermissionMap {
-  if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
-
-  const action = getAction(value)
-  if (action) return { "*": action }
-
-  return {}
-}
-
-function getAction(value: unknown): PermissionAction | undefined {
-  if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
-  return
-}
-
-function getRuleDefault(value: unknown): PermissionAction | undefined {
-  const action = getAction(value)
-  if (action) return action
-
-  if (!value || typeof value !== "object" || Array.isArray(value)) return
-
-  return getAction((value as Record<string, unknown>)["*"])
-}
-
-export const SettingsPermissions: Component = () => {
-  const globalSync = useGlobalSync()
-  const language = useLanguage()
-
-  const actions = createMemo(
-    (): Array<{ value: PermissionAction; label: string }> =>
-      ACTIONS.map((action) => ({
-        value: action.value,
-        label: language.t(action.label),
-      })),
-  )
-
-  const permission = createMemo(() => {
-    return toMap(globalSync.data.config.permission)
-  })
-
-  const actionFor = (id: string): PermissionAction => {
-    const value = permission()[id]
-    const direct = getRuleDefault(value)
-    if (direct) return direct
-
-    const wildcard = getRuleDefault(permission()["*"])
-    if (wildcard) return wildcard
-
-    return "allow"
-  }
-
-  const setPermission = async (id: string, action: PermissionAction) => {
-    const before = globalSync.data.config.permission
-    const map = toMap(before)
-    const existing = map[id]
-
-    const nextValue =
-      existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
-
-    const rollback = (err: unknown) => {
-      globalSync.set("config", "permission", before)
-      const message = err instanceof Error ? err.message : String(err)
-      showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
-    }
-
-    globalSync.set("config", "permission", { ...map, [id]: nextValue })
-    globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
-  }
-
-  return (
-    <div class="flex flex-col h-full overflow-y-auto no-scrollbar">
-      <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
-        <div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
-          <h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
-          <p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
-        </div>
-      </div>
-
-      <div class="flex flex-col gap-6 px-4 py-6 sm:p-8 sm:pt-6 max-w-[720px]">
-        <div class="flex flex-col gap-2">
-          <h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
-          <div class="border border-border-weak-base rounded-lg overflow-hidden">
-            <For each={ITEMS}>
-              {(item) => (
-                <SettingsRow title={language.t(item.title)} description={language.t(item.description)}>
-                  <Select
-                    options={actions()}
-                    current={actions().find((o) => o.value === actionFor(item.id))}
-                    value={(o) => o.value}
-                    label={(o) => o.label}
-                    onSelect={(option) => option && setPermission(item.id, option.value)}
-                    variant="secondary"
-                    size="small"
-                    triggerVariant="settings"
-                  />
-                </SettingsRow>
-              )}
-            </For>
-          </div>
-        </div>
-      </div>
-    </div>
-  )
-}
-
-interface SettingsRowProps {
-  title: string
-  description: string
-  children: JSX.Element
-}
-
-const SettingsRow: Component<SettingsRowProps> = (props) => {
-  return (
-    <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
-      <div class="flex flex-col gap-0.5 min-w-0">
-        <span class="text-14-medium text-text-strong">{props.title}</span>
-        <span class="text-12-regular text-text-weak">{props.description}</span>
-      </div>
-      <div class="flex-shrink-0">{props.children}</div>
-    </div>
-  )
-}

+ 2 - 6
packages/app/src/context/global-sync.test.ts

@@ -1,10 +1,6 @@
 import { describe, expect, test } from "bun:test"
-import {
-  canDisposeDirectory,
-  estimateRootSessionTotal,
-  loadRootSessionsWithFallback,
-  pickDirectoriesToEvict,
-} from "./global-sync"
+import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
+import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
 
 describe("pickDirectoriesToEvict", () => {
   test("keeps pinned stores and evicts idle stores", () => {

+ 0 - 3
packages/app/src/context/global-sync.tsx

@@ -402,6 +402,3 @@ export function useGlobalSync() {
   if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
   return context
 }
-
-export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
-export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"

+ 0 - 66
packages/app/src/context/notification-index.ts

@@ -1,66 +0,0 @@
-type NotificationIndexItem = {
-  directory?: string
-  session?: string
-  viewed: boolean
-  type: string
-}
-
-export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
-  const sessionAll = new Map<string, T[]>()
-  const sessionUnseen = new Map<string, T[]>()
-  const sessionUnseenCount = new Map<string, number>()
-  const sessionUnseenHasError = new Map<string, boolean>()
-  const projectAll = new Map<string, T[]>()
-  const projectUnseen = new Map<string, T[]>()
-  const projectUnseenCount = new Map<string, number>()
-  const projectUnseenHasError = new Map<string, boolean>()
-
-  for (const notification of list) {
-    const session = notification.session
-    if (session) {
-      const all = sessionAll.get(session)
-      if (all) all.push(notification)
-      else sessionAll.set(session, [notification])
-
-      if (!notification.viewed) {
-        const unseen = sessionUnseen.get(session)
-        if (unseen) unseen.push(notification)
-        else sessionUnseen.set(session, [notification])
-
-        sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
-        if (notification.type === "error") sessionUnseenHasError.set(session, true)
-      }
-    }
-
-    const directory = notification.directory
-    if (directory) {
-      const all = projectAll.get(directory)
-      if (all) all.push(notification)
-      else projectAll.set(directory, [notification])
-
-      if (!notification.viewed) {
-        const unseen = projectUnseen.get(directory)
-        if (unseen) unseen.push(notification)
-        else projectUnseen.set(directory, [notification])
-
-        projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
-        if (notification.type === "error") projectUnseenHasError.set(directory, true)
-      }
-    }
-  }
-
-  return {
-    session: {
-      all: sessionAll,
-      unseen: sessionUnseen,
-      unseenCount: sessionUnseenCount,
-      unseenHasError: sessionUnseenHasError,
-    },
-    project: {
-      all: projectAll,
-      unseen: projectUnseen,
-      unseenCount: projectUnseenCount,
-      unseenHasError: projectUnseenHasError,
-    },
-  }
-}

+ 0 - 73
packages/app/src/context/notification.test.ts

@@ -1,73 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { buildNotificationIndex } from "./notification-index"
-
-type Notification = {
-  type: "turn-complete" | "error"
-  session: string
-  directory: string
-  viewed: boolean
-  time: number
-}
-
-const turn = (session: string, directory: string, viewed = false): Notification => ({
-  type: "turn-complete",
-  session,
-  directory,
-  viewed,
-  time: 1,
-})
-
-const error = (session: string, directory: string, viewed = false): Notification => ({
-  type: "error",
-  session,
-  directory,
-  viewed,
-  time: 1,
-})
-
-describe("buildNotificationIndex", () => {
-  test("builds unseen counts and unseen error flags", () => {
-    const list = [
-      turn("s1", "d1", false),
-      error("s1", "d1", false),
-      turn("s1", "d1", true),
-      turn("s2", "d1", false),
-      error("s3", "d2", true),
-    ]
-
-    const index = buildNotificationIndex(list)
-
-    expect(index.session.all.get("s1")?.length).toBe(3)
-    expect(index.session.unseen.get("s1")?.length).toBe(2)
-    expect(index.session.unseenCount.get("s1")).toBe(2)
-    expect(index.session.unseenHasError.get("s1")).toBe(true)
-
-    expect(index.session.unseenCount.get("s2")).toBe(1)
-    expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
-    expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
-    expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
-
-    expect(index.project.unseenCount.get("d1")).toBe(3)
-    expect(index.project.unseenHasError.get("d1")).toBe(true)
-    expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
-    expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
-  })
-
-  test("updates selectors after viewed transitions", () => {
-    const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
-    const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
-
-    const before = buildNotificationIndex(list)
-    const after = buildNotificationIndex(next)
-
-    expect(before.session.unseenCount.get("s1")).toBe(2)
-    expect(before.session.unseenHasError.get("s1")).toBe(true)
-    expect(before.project.unseenCount.get("d1")).toBe(3)
-    expect(before.project.unseenHasError.get("d1")).toBe(true)
-
-    expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
-    expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
-    expect(after.project.unseenCount.get("d1")).toBe(1)
-    expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
-  })
-})

+ 2 - 4
packages/app/src/pages/layout.tsx

@@ -51,7 +51,7 @@ import { DialogSelectProvider } from "@/components/dialog-select-provider"
 import { DialogSelectServer } from "@/components/dialog-select-server"
 import { DialogSettings } from "@/components/dialog-settings"
 import { useCommand, type CommandOption } from "@/context/command"
-import { ConstrainDragXAxis } from "@/utils/solid-dnd"
+import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
 import { DialogSelectDirectory } from "@/components/dialog-select-directory"
 import { DialogEditProject } from "@/components/dialog-edit-project"
 import { DebugBar } from "@/components/debug-bar"
@@ -62,7 +62,6 @@ import {
   displayName,
   effectiveWorkspaceOrder,
   errorMessage,
-  getDraggableId,
   latestRootSession,
   sortedRootSessions,
   workspaceKey,
@@ -80,7 +79,6 @@ import {
   WorkspaceDragOverlay,
   type WorkspaceSidebarContext,
 } from "./layout/sidebar-workspace"
-import { workspaceOpenState } from "./layout/sidebar-workspace-helpers"
 import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
 import { SidebarContent } from "./layout/sidebar-shell"
 
@@ -1860,7 +1858,7 @@ export default function Layout(props: ParentProps) {
     setEditor,
     InlineEditor,
     isBusy,
-    workspaceExpanded: (directory, local) => workspaceOpenState(store.workspaceExpanded, directory, local),
+    workspaceExpanded: (directory, local) => store.workspaceExpanded[directory] ?? local,
     setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
     showResetWorkspaceDialog: (root, directory) =>
       dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),

+ 8 - 8
packages/app/src/pages/layout/helpers.test.ts

@@ -6,9 +6,15 @@ import {
   parseDeepLink,
   parseNewSessionDeepLink,
 } from "./deep-links"
-import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
 import { type Session } from "@opencode-ai/sdk/v2/client"
-import { hasProjectPermissions, latestRootSession } from "./helpers"
+import {
+  displayName,
+  errorMessage,
+  hasProjectPermissions,
+  latestRootSession,
+  syncWorkspaceOrder,
+  workspaceKey,
+} from "./helpers"
 
 const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
   ({
@@ -192,12 +198,6 @@ describe("layout workspace helpers", () => {
     expect(result?.id).toBe("root")
   })
 
-  test("extracts draggable id safely", () => {
-    expect(getDraggableId({ draggable: { id: "x" } })).toBe("x")
-    expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined()
-    expect(getDraggableId(null)).toBeUndefined()
-  })
-
   test("formats fallback project display name", () => {
     expect(displayName({ worktree: "/tmp/app" })).toBe("app")
     expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")

+ 0 - 8
packages/app/src/pages/layout/helpers.ts

@@ -54,14 +54,6 @@ export const childMapByParent = (sessions: Session[]) => {
   return map
 }
 
-export function 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 displayName = (project: { name?: string; worktree: string }) =>
   project.name || getFilename(project.worktree)
 

+ 0 - 63
packages/app/src/pages/layout/sidebar-project-helpers.test.ts

@@ -1,63 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
-
-describe("projectSelected", () => {
-  test("matches direct worktree", () => {
-    expect(projectSelected("/tmp/root", "/tmp/root")).toBe(true)
-  })
-
-  test("matches sandbox worktree", () => {
-    expect(projectSelected("/tmp/branch", "/tmp/root", ["/tmp/branch"])).toBe(true)
-    expect(projectSelected("/tmp/other", "/tmp/root", ["/tmp/branch"])).toBe(false)
-  })
-})
-
-describe("projectTileActive", () => {
-  test("menu state always wins", () => {
-    expect(
-      projectTileActive({
-        menu: true,
-        preview: false,
-        open: false,
-        overlay: false,
-        worktree: "/tmp/root",
-      }),
-    ).toBe(true)
-  })
-
-  test("preview mode uses open state", () => {
-    expect(
-      projectTileActive({
-        menu: false,
-        preview: true,
-        open: true,
-        overlay: true,
-        hoverProject: "/tmp/other",
-        worktree: "/tmp/root",
-      }),
-    ).toBe(true)
-  })
-
-  test("overlay mode uses hovered project", () => {
-    expect(
-      projectTileActive({
-        menu: false,
-        preview: false,
-        open: false,
-        overlay: true,
-        hoverProject: "/tmp/root",
-        worktree: "/tmp/root",
-      }),
-    ).toBe(true)
-    expect(
-      projectTileActive({
-        menu: false,
-        preview: false,
-        open: false,
-        overlay: true,
-        hoverProject: "/tmp/other",
-        worktree: "/tmp/root",
-      }),
-    ).toBe(false)
-  })
-})

+ 0 - 11
packages/app/src/pages/layout/sidebar-project-helpers.ts

@@ -1,11 +0,0 @@
-export const projectSelected = (currentDir: string, worktree: string, sandboxes?: string[]) =>
-  worktree === currentDir || sandboxes?.includes(currentDir) === true
-
-export const projectTileActive = (args: {
-  menu: boolean
-  preview: boolean
-  open: boolean
-  overlay: boolean
-  hoverProject?: string
-  worktree: string
-}) => args.menu || (args.preview ? args.open : args.overlay && args.hoverProject === args.worktree)

+ 6 - 12
packages/app/src/pages/layout/sidebar-project.tsx

@@ -12,7 +12,6 @@ import { useLanguage } from "@/context/language"
 import { useNotification } from "@/context/notification"
 import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
 import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
-import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
 
 export type ProjectSidebarContext = {
   currentDir: Accessor<string>
@@ -277,8 +276,10 @@ export const SortableProject = (props: {
   const globalSync = useGlobalSync()
   const language = useLanguage()
   const sortable = createSortable(props.project.worktree)
-  const selected = createMemo(() =>
-    projectSelected(props.ctx.currentDir(), props.project.worktree, props.project.sandboxes),
+  const selected = createMemo(
+    () =>
+      props.project.worktree === props.ctx.currentDir() ||
+      props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
   )
   const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
   const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
@@ -291,15 +292,8 @@ export const SortableProject = (props: {
 
   const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
   const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
-  const active = createMemo(() =>
-    projectTileActive({
-      menu: state.menu,
-      preview: preview(),
-      open: state.open,
-      overlay: overlay(),
-      hoverProject: props.ctx.hoverProject(),
-      worktree: props.project.worktree,
-    }),
+  const active = createMemo(
+    () => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
   )
 
   createEffect(() => {

+ 0 - 1
packages/app/src/pages/layout/sidebar-shell-helpers.ts

@@ -1 +0,0 @@
-export const sidebarExpanded = (mobile: boolean | undefined, opened: boolean) => !!mobile || opened

+ 0 - 13
packages/app/src/pages/layout/sidebar-shell.test.ts

@@ -1,13 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { sidebarExpanded } from "./sidebar-shell-helpers"
-
-describe("sidebarExpanded", () => {
-  test("expands on mobile regardless of desktop open state", () => {
-    expect(sidebarExpanded(true, false)).toBe(true)
-  })
-
-  test("follows desktop open state when not mobile", () => {
-    expect(sidebarExpanded(false, true)).toBe(true)
-    expect(sidebarExpanded(false, false)).toBe(false)
-  })
-})

+ 1 - 2
packages/app/src/pages/layout/sidebar-shell.tsx

@@ -11,7 +11,6 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { type LocalProject } from "@/context/layout"
-import { sidebarExpanded } from "./sidebar-shell-helpers"
 
 export const SidebarContent = (props: {
   mobile?: boolean
@@ -33,7 +32,7 @@ export const SidebarContent = (props: {
   onOpenHelp: () => void
   renderPanel: () => JSX.Element
 }): JSX.Element => {
-  const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
+  const expanded = createMemo(() => !!props.mobile || props.opened())
   const placement = () => (props.mobile ? "bottom" : "right")
   let panel: HTMLDivElement | undefined
 

+ 0 - 2
packages/app/src/pages/layout/sidebar-workspace-helpers.ts

@@ -1,2 +0,0 @@
-export const workspaceOpenState = (expanded: Record<string, boolean>, directory: string, local: boolean) =>
-  expanded[directory] ?? local

+ 0 - 13
packages/app/src/pages/layout/sidebar-workspace.test.ts

@@ -1,13 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { workspaceOpenState } from "./sidebar-workspace-helpers"
-
-describe("workspaceOpenState", () => {
-  test("defaults to local workspace open", () => {
-    expect(workspaceOpenState({}, "/tmp/root", true)).toBe(true)
-  })
-
-  test("uses persisted expansion state when present", () => {
-    expect(workspaceOpenState({ "/tmp/root": false }, "/tmp/root", true)).toBe(false)
-    expect(workspaceOpenState({ "/tmp/branch": true }, "/tmp/branch", false)).toBe(true)
-  })
-})

+ 30 - 70
packages/app/src/pages/layout/sidebar-workspace.tsx

@@ -144,8 +144,6 @@ const WorkspaceActions = (props: {
   setMenuOpen: (open: boolean) => void
   setPendingRename: (value: boolean) => void
   sidebarHovering: Accessor<boolean>
-  mobile?: boolean
-  nav: Accessor<HTMLElement | undefined>
   touch: Accessor<boolean>
   language: ReturnType<typeof useLanguage>
   workspaceValue: Accessor<string>
@@ -340,6 +338,22 @@ export const SortableWorkspace = (props: {
   }
 
   const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
+  const header = () => (
+    <WorkspaceHeader
+      local={local}
+      busy={busy}
+      open={open}
+      directory={props.directory}
+      language={language}
+      branch={() => workspaceStore.vcs?.branch}
+      workspaceValue={workspaceValue}
+      workspaceEditActive={workspaceEditActive}
+      InlineEditor={props.ctx.InlineEditor}
+      renameWorkspace={props.ctx.renameWorkspace}
+      setEditor={props.ctx.setEditor}
+      projectId={props.project.id}
+    />
+  )
 
   const openWrapper = (value: boolean) => {
     props.ctx.setWorkspaceExpanded(props.directory, value)
@@ -379,20 +393,7 @@ export const SortableWorkspace = (props: {
                     data-action="workspace-toggle"
                     data-workspace={base64Encode(props.directory)}
                   >
-                    <WorkspaceHeader
-                      local={local}
-                      busy={busy}
-                      open={open}
-                      directory={props.directory}
-                      language={language}
-                      branch={() => workspaceStore.vcs?.branch}
-                      workspaceValue={workspaceValue}
-                      workspaceEditActive={workspaceEditActive}
-                      InlineEditor={props.ctx.InlineEditor}
-                      renameWorkspace={props.ctx.renameWorkspace}
-                      setEditor={props.ctx.setEditor}
-                      projectId={props.project.id}
-                    />
+                    {header()}
                   </Collapsible.Trigger>
                 }
               >
@@ -401,20 +402,7 @@ export const SortableWorkspace = (props: {
                     menu.open ? "pr-16" : "pr-2"
                   } group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
                 >
-                  <WorkspaceHeader
-                    local={local}
-                    busy={busy}
-                    open={open}
-                    directory={props.directory}
-                    language={language}
-                    branch={() => workspaceStore.vcs?.branch}
-                    workspaceValue={workspaceValue}
-                    workspaceEditActive={workspaceEditActive}
-                    InlineEditor={props.ctx.InlineEditor}
-                    renameWorkspace={props.ctx.renameWorkspace}
-                    setEditor={props.ctx.setEditor}
-                    projectId={props.project.id}
-                  />
+                  {header()}
                 </div>
               </Show>
               <WorkspaceActions
@@ -426,8 +414,6 @@ export const SortableWorkspace = (props: {
                 setMenuOpen={(open) => setMenu("open", open)}
                 setPendingRename={(value) => setMenu("pendingRename", value)}
                 sidebarHovering={props.ctx.sidebarHovering}
-                mobile={props.mobile}
-                nav={props.ctx.nav}
                 touch={touch}
                 language={language}
                 workspaceValue={workspaceValue}
@@ -490,44 +476,18 @@ export const LocalWorkspace = (props: {
       ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
       class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
     >
-      <nav class="flex flex-col gap-1 px-3">
-        <Show when={loading()}>
-          <SessionSkeleton />
-        </Show>
-        <For each={sessions()}>
-          {(session) => (
-            <SessionItem
-              session={session}
-              slug={slug()}
-              mobile={props.mobile}
-              children={children()}
-              sidebarExpanded={props.ctx.sidebarExpanded}
-              sidebarHovering={props.ctx.sidebarHovering}
-              nav={props.ctx.nav}
-              hoverSession={props.ctx.hoverSession}
-              setHoverSession={props.ctx.setHoverSession}
-              clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
-              prefetchSession={props.ctx.prefetchSession}
-              archiveSession={props.ctx.archiveSession}
-            />
-          )}
-        </For>
-        <Show when={hasMore()}>
-          <div class="relative w-full py-1">
-            <Button
-              variant="ghost"
-              class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
-              size="large"
-              onClick={(e: MouseEvent) => {
-                loadMore()
-                ;(e.currentTarget as HTMLButtonElement).blur()
-              }}
-            >
-              {language.t("common.loadMore")}
-            </Button>
-          </div>
-        </Show>
-      </nav>
+      <WorkspaceSessionList
+        slug={slug}
+        mobile={props.mobile}
+        ctx={props.ctx}
+        showNew={() => false}
+        loading={loading}
+        sessions={sessions}
+        children={children}
+        hasMore={hasMore}
+        loadMore={loadMore}
+        language={language}
+      />
     </div>
   )
 }

+ 1 - 2
packages/app/src/pages/session/composer/index.ts

@@ -1,3 +1,2 @@
 export { SessionComposerRegion } from "./session-composer-region"
-export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
-export type { SessionComposerState } from "./session-composer-state"
+export { createSessionComposerState } from "./session-composer-state"

+ 0 - 10
packages/app/src/pages/session/composer/session-composer-helpers.ts

@@ -1,10 +0,0 @@
-export const todoState = (input: {
-  count: number
-  done: boolean
-  live: boolean
-}): "hide" | "clear" | "open" | "close" => {
-  if (input.count === 0) return "hide"
-  if (!input.live) return "clear"
-  if (!input.done) return "open"
-  return "close"
-}

+ 1 - 1
packages/app/src/pages/session/composer/session-composer-state.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
-import { todoState } from "./session-composer-helpers"
+import { todoState } from "./session-composer-state"
 import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
 
 const session = (input: { id: string; parentID?: string }) =>

+ 11 - 20
packages/app/src/pages/session/composer/session-composer-state.ts

@@ -8,30 +8,21 @@ import { useLanguage } from "@/context/language"
 import { usePermission } from "@/context/permission"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
-import { todoState } from "./session-composer-helpers"
 import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
 
-const idle = { type: "idle" as const }
-
-export function createSessionComposerBlocked() {
-  const params = useParams()
-  const permission = usePermission()
-  const sdk = useSDK()
-  const sync = useSync()
-  const permissionRequest = createMemo(() =>
-    sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => {
-      return !permission.autoResponds(item, sdk.directory)
-    }),
-  )
-  const questionRequest = createMemo(() => sessionQuestionRequest(sync.data.session, sync.data.question, params.id))
-
-  return createMemo(() => {
-    const id = params.id
-    if (!id) return false
-    return !!permissionRequest() || !!questionRequest()
-  })
+export const todoState = (input: {
+  count: number
+  done: boolean
+  live: boolean
+}): "hide" | "clear" | "open" | "close" => {
+  if (input.count === 0) return "hide"
+  if (!input.live) return "clear"
+  if (!input.done) return "open"
+  return "close"
 }
 
+const idle = { type: "idle" as const }
+
 export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
   const params = useParams()
   const sdk = useSDK()

+ 1 - 55
packages/app/src/pages/session/helpers.ts

@@ -1,4 +1,4 @@
-import { batch, createEffect, on, onCleanup, onMount, type Accessor } from "solid-js"
+import { batch, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
 
 export const focusTerminalById = (id: string) => {
@@ -117,57 +117,3 @@ export const createSizing = () => {
 }
 
 export type Sizing = ReturnType<typeof createSizing>
-
-export const createPresence = (open: Accessor<boolean>, wait = 200) => {
-  const [state, setState] = createStore({
-    show: open(),
-    open: open(),
-  })
-  let frame: number | undefined
-  let t: number | undefined
-
-  const clear = () => {
-    if (frame !== undefined) {
-      cancelAnimationFrame(frame)
-      frame = undefined
-    }
-    if (t !== undefined) {
-      clearTimeout(t)
-      t = undefined
-    }
-  }
-
-  createEffect(
-    on(open, (next) => {
-      clear()
-
-      if (next) {
-        if (state.show) {
-          setState("open", true)
-          return
-        }
-
-        setState({ show: true, open: false })
-        frame = requestAnimationFrame(() => {
-          frame = undefined
-          setState("open", true)
-        })
-        return
-      }
-
-      if (!state.show) return
-      setState("open", false)
-      t = window.setTimeout(() => {
-        t = undefined
-        setState("show", false)
-      }, wait)
-    }),
-  )
-
-  onCleanup(clear)
-
-  return {
-    show: () => state.show,
-    open: () => state.open,
-  }
-}

+ 0 - 10
packages/app/src/pages/session/session-command-helpers.ts

@@ -1,10 +0,0 @@
-export const canAddSelectionContext = (input: {
-  active?: string
-  pathFromTab: (tab: string) => string | undefined
-  selectedLines: (path: string) => unknown
-}) => {
-  if (!input.active) return false
-  const path = input.pathFromTab(input.active)
-  if (!path) return false
-  return input.selectedLines(path) != null
-}

+ 0 - 22
packages/app/src/pages/session/session-prompt-dock.test.ts

@@ -1,22 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { questionSubtitle } from "./session-prompt-helpers"
-
-describe("questionSubtitle", () => {
-  const t = (key: string) => {
-    if (key === "ui.common.question.one") return "question"
-    if (key === "ui.common.question.other") return "questions"
-    return key
-  }
-
-  test("returns empty for zero", () => {
-    expect(questionSubtitle(0, t)).toBe("")
-  })
-
-  test("uses singular label", () => {
-    expect(questionSubtitle(1, t)).toBe("1 question")
-  })
-
-  test("uses plural label", () => {
-    expect(questionSubtitle(3, t)).toBe("3 questions")
-  })
-})

+ 0 - 4
packages/app/src/pages/session/session-prompt-helpers.ts

@@ -1,4 +0,0 @@
-export const questionSubtitle = (count: number, t: (key: string) => string) => {
-  if (count === 0) return ""
-  return `${count} ${t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
-}

+ 0 - 44
packages/app/src/pages/session/use-session-commands.test.ts

@@ -1,44 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { canAddSelectionContext } from "./session-command-helpers"
-
-describe("canAddSelectionContext", () => {
-  test("returns false without active tab", () => {
-    expect(
-      canAddSelectionContext({
-        active: undefined,
-        pathFromTab: () => "src/a.ts",
-        selectedLines: () => ({ start: 1, end: 1 }),
-      }),
-    ).toBe(false)
-  })
-
-  test("returns false when active tab is not a file", () => {
-    expect(
-      canAddSelectionContext({
-        active: "context",
-        pathFromTab: () => undefined,
-        selectedLines: () => ({ start: 1, end: 1 }),
-      }),
-    ).toBe(false)
-  })
-
-  test("returns false without selected lines", () => {
-    expect(
-      canAddSelectionContext({
-        active: "file://src/a.ts",
-        pathFromTab: () => "src/a.ts",
-        selectedLines: () => null,
-      }),
-    ).toBe(false)
-  })
-
-  test("returns true when file and selection exist", () => {
-    expect(
-      canAddSelectionContext({
-        active: "file://src/a.ts",
-        pathFromTab: () => "src/a.ts",
-        selectedLines: () => ({ start: 1, end: 2 }),
-      }),
-    ).toBe(true)
-  })
-})

+ 9 - 6
packages/app/src/pages/session/use-session-commands.tsx

@@ -19,7 +19,6 @@ import { showToast } from "@opencode-ai/ui/toast"
 import { findLast } from "@opencode-ai/util/array"
 import { extractPromptFromParts } from "@/utils/prompt"
 import { UserMessage } from "@opencode-ai/sdk/v2"
-import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
 
 export type SessionCommandContext = {
   navigateMessageByOffset: (offset: number) => void
@@ -84,6 +83,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
     prompt.context.add({ type: "file", path, selection, preview })
   }
 
+  const canAddSelectionContext = () => {
+    const active = tabs().active()
+    if (!active) return false
+    const path = file.pathFromTab(active)
+    if (!path) return false
+    return file.selectedLines(path) != null
+  }
+
   const navigateMessageByOffset = actions.navigateMessageByOffset
   const setActiveMessage = actions.setActiveMessage
   const focusInput = actions.focusInput
@@ -136,11 +143,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
       title: language.t("command.context.addSelection"),
       description: language.t("command.context.addSelection.description"),
       keybind: "mod+shift+l",
-      disabled: !canAddSelectionContext({
-        active: tabs().active(),
-        pathFromTab: file.pathFromTab,
-        selectedLines: file.selectedLines,
-      }),
+      disabled: !canAddSelectionContext(),
       onSelect: () => {
         const active = tabs().active()
         if (!active) return

+ 0 - 2
packages/app/src/pages/session/use-session-hash-scroll.ts

@@ -3,8 +3,6 @@ import { useLocation, useNavigate } from "@solidjs/router"
 import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
 import { messageIdFromHash } from "./message-id-from-hash"
 
-export { messageIdFromHash } from "./message-id-from-hash"
-
 export const useSessionHashScroll = (input: {
   sessionKey: () => string
   sessionID: () => string | undefined

+ 0 - 51
packages/app/src/utils/dom.ts

@@ -1,51 +0,0 @@
-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 }
-}

+ 0 - 1
packages/app/src/utils/index.ts

@@ -1 +0,0 @@
-export * from "./dom"

+ 0 - 326
packages/app/src/utils/speech.ts

@@ -1,326 +0,0 @@
-import { onCleanup } from "solid-js"
-import { createStore } from "solid-js/store"
-import { getSpeechRecognitionCtor } from "@/utils/runtime-adapters"
-
-// 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 ctor = getSpeechRecognitionCtor<Recognition>(typeof window === "undefined" ? undefined : window)
-  const hasSupport = Boolean(ctor)
-
-  const [store, setStore] = createStore({
-    isRecording: false,
-    committed: "",
-    interim: "",
-  })
-
-  const isRecording = () => store.isRecording
-  const committed = () => store.committed
-  const interim = () => store.interim
-
-  let recognition: Recognition | undefined
-  let shouldContinue = false
-  let committedText = ""
-  let sessionCommitted = ""
-  let pendingHypothesis = ""
-  let lastInterimSuffix = ""
-  let shrinkCandidate: string | undefined
-  let commitTimer: number | undefined
-  let restartTimer: number | undefined
-
-  const cancelPendingCommit = () => {
-    if (commitTimer === undefined) return
-    clearTimeout(commitTimer)
-    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
-    committedText = nextCommitted
-    setStore("committed", 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
-    setStore("interim", "")
-    if (opts?.onInterim) opts.onInterim("")
-  }
-
-  const applyInterim = (suffix: string, hypothesis: string) => {
-    cancelPendingCommit()
-    pendingHypothesis = hypothesis
-    lastInterimSuffix = suffix
-    shrinkCandidate = undefined
-    setStore("interim", 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
-      setStore("interim", "")
-      if (opts?.onInterim) opts.onInterim("")
-    }, COMMIT_DELAY)
-  }
-
-  if (ctor) {
-    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
-        setStore("interim", "")
-        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 }) => {
-      clearRestart()
-      cancelPendingCommit()
-      lastInterimSuffix = ""
-      shrinkCandidate = undefined
-      if (e.error === "no-speech" && shouldContinue) {
-        setStore("interim", "")
-        if (opts?.onInterim) opts.onInterim("")
-        scheduleRestart()
-        return
-      }
-      shouldContinue = false
-      setStore("isRecording", false)
-    }
-
-    recognition.onstart = () => {
-      clearRestart()
-      sessionCommitted = ""
-      pendingHypothesis = ""
-      cancelPendingCommit()
-      lastInterimSuffix = ""
-      shrinkCandidate = undefined
-      setStore("interim", "")
-      if (opts?.onInterim) opts.onInterim("")
-      setStore("isRecording", true)
-    }
-
-    recognition.onend = () => {
-      clearRestart()
-      cancelPendingCommit()
-      lastInterimSuffix = ""
-      shrinkCandidate = undefined
-      setStore("isRecording", false)
-      if (shouldContinue) {
-        scheduleRestart()
-      }
-    }
-  }
-
-  const start = () => {
-    if (!recognition) return
-    clearRestart()
-    shouldContinue = true
-    sessionCommitted = ""
-    pendingHypothesis = ""
-    cancelPendingCommit()
-    lastInterimSuffix = ""
-    shrinkCandidate = undefined
-    setStore("interim", "")
-    try {
-      recognition.start()
-    } catch {}
-  }
-
-  const stop = () => {
-    if (!recognition) return
-    shouldContinue = false
-    clearRestart()
-    promotePending()
-    cancelPendingCommit()
-    lastInterimSuffix = ""
-    shrinkCandidate = undefined
-    setStore("interim", "")
-    if (opts?.onInterim) opts.onInterim("")
-    try {
-      recognition.stop()
-    } catch {}
-  }
-
-  onCleanup(() => {
-    shouldContinue = false
-    clearRestart()
-    promotePending()
-    cancelPendingCommit()
-    lastInterimSuffix = ""
-    shrinkCandidate = undefined
-    setStore("interim", "")
-    if (opts?.onInterim) opts.onInterim("")
-    try {
-      recognition?.stop()
-    } catch {}
-  })
-
-  return {
-    isSupported: () => hasSupport,
-    isRecording,
-    committed,
-    interim,
-    start,
-    stop,
-  }
-}