diff options
| author | Adam <[email protected]> | 2026-03-12 07:26:43 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-12 12:26:43 +0000 |
| commit | d4107d51f163b74c96895b50d653828c698d937e (patch) | |
| tree | 070ae9ad38ebd230d75517d485fc0c86f2263c6b | |
| parent | d8fbe0af0151c662008d8a2dbbb7c76ff5db88d4 (diff) | |
| download | opencode-d4107d51f163b74c96895b50d653828c698d937e.tar.gz opencode-d4107d51f163b74c96895b50d653828c698d937e.zip | |
chore: cleanup (#17115)
35 files changed, 75 insertions, 1202 deletions
diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index 6fe6186d5..4f49911c1 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/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) => { diff --git a/packages/app/src/components/settings-agents.tsx b/packages/app/src/components/settings-agents.tsx deleted file mode 100644 index 74a942f77..000000000 --- a/packages/app/src/components/settings-agents.tsx +++ /dev/null @@ -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> - ) -} diff --git a/packages/app/src/components/settings-commands.tsx b/packages/app/src/components/settings-commands.tsx deleted file mode 100644 index e158d231c..000000000 --- a/packages/app/src/components/settings-commands.tsx +++ /dev/null @@ -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> - ) -} diff --git a/packages/app/src/components/settings-mcp.tsx b/packages/app/src/components/settings-mcp.tsx deleted file mode 100644 index 507e041aa..000000000 --- a/packages/app/src/components/settings-mcp.tsx +++ /dev/null @@ -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> - ) -} diff --git a/packages/app/src/components/settings-permissions.tsx b/packages/app/src/components/settings-permissions.tsx deleted file mode 100644 index 5c922ba44..000000000 --- a/packages/app/src/components/settings-permissions.tsx +++ /dev/null @@ -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> - ) -} diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts index 7956057fd..93e9c4175 100644 --- a/packages/app/src/context/global-sync.test.ts +++ b/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", () => { diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 4090699a8..645bd678b 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/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" diff --git a/packages/app/src/context/notification-index.ts b/packages/app/src/context/notification-index.ts deleted file mode 100644 index 0b316e7ec..000000000 --- a/packages/app/src/context/notification-index.ts +++ /dev/null @@ -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, - }, - } -} diff --git a/packages/app/src/context/notification.test.ts b/packages/app/src/context/notification.test.ts deleted file mode 100644 index 44bacb704..000000000 --- a/packages/app/src/context/notification.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 052a03c54..daad100c3 100644 --- a/packages/app/src/pages/layout.tsx +++ b/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} />), diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index d1569dbd9..916b80214 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/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") diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 42315e589..8881b8a48 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/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) diff --git a/packages/app/src/pages/layout/sidebar-project-helpers.test.ts b/packages/app/src/pages/layout/sidebar-project-helpers.test.ts deleted file mode 100644 index 75958d49e..000000000 --- a/packages/app/src/pages/layout/sidebar-project-helpers.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/packages/app/src/pages/layout/sidebar-project-helpers.ts b/packages/app/src/pages/layout/sidebar-project-helpers.ts deleted file mode 100644 index 06d38a3cd..000000000 --- a/packages/app/src/pages/layout/sidebar-project-helpers.ts +++ /dev/null @@ -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) diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 187cd2f33..551090fd5 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/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(() => { diff --git a/packages/app/src/pages/layout/sidebar-shell-helpers.ts b/packages/app/src/pages/layout/sidebar-shell-helpers.ts deleted file mode 100644 index 93c286c15..000000000 --- a/packages/app/src/pages/layout/sidebar-shell-helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export const sidebarExpanded = (mobile: boolean | undefined, opened: boolean) => !!mobile || opened diff --git a/packages/app/src/pages/layout/sidebar-shell.test.ts b/packages/app/src/pages/layout/sidebar-shell.test.ts deleted file mode 100644 index 694025a65..000000000 --- a/packages/app/src/pages/layout/sidebar-shell.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index d3070e374..82be4f024 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/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 diff --git a/packages/app/src/pages/layout/sidebar-workspace-helpers.ts b/packages/app/src/pages/layout/sidebar-workspace-helpers.ts deleted file mode 100644 index aa7cb480e..000000000 --- a/packages/app/src/pages/layout/sidebar-workspace-helpers.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const workspaceOpenState = (expanded: Record<string, boolean>, directory: string, local: boolean) => - expanded[directory] ?? local diff --git a/packages/app/src/pages/layout/sidebar-workspace.test.ts b/packages/app/src/pages/layout/sidebar-workspace.test.ts deleted file mode 100644 index d71c39fc8..000000000 --- a/packages/app/src/pages/layout/sidebar-workspace.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index c317b9c5e..1a54fdd8a 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/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> ) } diff --git a/packages/app/src/pages/session/composer/index.ts b/packages/app/src/pages/session/composer/index.ts index e244a1536..b0069de53 100644 --- a/packages/app/src/pages/session/composer/index.ts +++ b/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" diff --git a/packages/app/src/pages/session/composer/session-composer-helpers.ts b/packages/app/src/pages/session/composer/session-composer-helpers.ts deleted file mode 100644 index 90c238af4..000000000 --- a/packages/app/src/pages/session/composer/session-composer-helpers.ts +++ /dev/null @@ -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" -} diff --git a/packages/app/src/pages/session/composer/session-composer-state.test.ts b/packages/app/src/pages/session/composer/session-composer-state.test.ts index f7c11715c..c27454f7e 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.test.ts +++ b/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 }) => diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index a007e4c84..525766dcf 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/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() diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index be9656900..2da5ce6b8 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/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, - } -} diff --git a/packages/app/src/pages/session/session-command-helpers.ts b/packages/app/src/pages/session/session-command-helpers.ts deleted file mode 100644 index b71a7b768..000000000 --- a/packages/app/src/pages/session/session-command-helpers.ts +++ /dev/null @@ -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 -} diff --git a/packages/app/src/pages/session/session-prompt-dock.test.ts b/packages/app/src/pages/session/session-prompt-dock.test.ts deleted file mode 100644 index b3a9945d6..000000000 --- a/packages/app/src/pages/session/session-prompt-dock.test.ts +++ /dev/null @@ -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") - }) -}) diff --git a/packages/app/src/pages/session/session-prompt-helpers.ts b/packages/app/src/pages/session/session-prompt-helpers.ts deleted file mode 100644 index ac3234c93..000000000 --- a/packages/app/src/pages/session/session-prompt-helpers.ts +++ /dev/null @@ -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")}` -} diff --git a/packages/app/src/pages/session/use-session-commands.test.ts b/packages/app/src/pages/session/use-session-commands.test.ts deleted file mode 100644 index ada1871e1..000000000 --- a/packages/app/src/pages/session/use-session-commands.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index b8ddeda82..ea3b5ec57 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/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 diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 1ea6a302b..5fadb1f22 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/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 diff --git a/packages/app/src/utils/dom.ts b/packages/app/src/utils/dom.ts deleted file mode 100644 index 4f3724c7c..000000000 --- a/packages/app/src/utils/dom.ts +++ /dev/null @@ -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 } -} diff --git a/packages/app/src/utils/index.ts b/packages/app/src/utils/index.ts deleted file mode 100644 index d87053269..000000000 --- a/packages/app/src/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./dom" diff --git a/packages/app/src/utils/speech.ts b/packages/app/src/utils/speech.ts deleted file mode 100644 index 52fc46b69..000000000 --- a/packages/app/src/utils/speech.ts +++ /dev/null @@ -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, - } -} |
