chore: cleanup (#17115)
This commit is contained in:
@@ -8,6 +8,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { focusTerminalById } from "@/pages/session/helpers"
|
||||||
|
|
||||||
export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
|
export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
@@ -53,21 +54,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
|
|
||||||
const focus = () => {
|
const focus = () => {
|
||||||
if (store.editing) return
|
if (store.editing) return
|
||||||
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
focusTerminalById(props.terminal.id)
|
||||||
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 }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const edit = (e?: Event) => {
|
const edit = (e?: Event) => {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||||
canDisposeDirectory,
|
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||||
estimateRootSessionTotal,
|
|
||||||
loadRootSessionsWithFallback,
|
|
||||||
pickDirectoriesToEvict,
|
|
||||||
} from "./global-sync"
|
|
||||||
|
|
||||||
describe("pickDirectoriesToEvict", () => {
|
describe("pickDirectoriesToEvict", () => {
|
||||||
test("keeps pinned stores and evicts idle stores", () => {
|
test("keeps pinned stores and evicts idle stores", () => {
|
||||||
|
|||||||
@@ -402,6 +402,3 @@ export function useGlobalSync() {
|
|||||||
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
|
||||||
export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -51,7 +51,7 @@ import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
|||||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||||
import { DialogSettings } from "@/components/dialog-settings"
|
import { DialogSettings } from "@/components/dialog-settings"
|
||||||
import { useCommand, type CommandOption } from "@/context/command"
|
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 { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
@@ -62,7 +62,6 @@ import {
|
|||||||
displayName,
|
displayName,
|
||||||
effectiveWorkspaceOrder,
|
effectiveWorkspaceOrder,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
getDraggableId,
|
|
||||||
latestRootSession,
|
latestRootSession,
|
||||||
sortedRootSessions,
|
sortedRootSessions,
|
||||||
workspaceKey,
|
workspaceKey,
|
||||||
@@ -80,7 +79,6 @@ import {
|
|||||||
WorkspaceDragOverlay,
|
WorkspaceDragOverlay,
|
||||||
type WorkspaceSidebarContext,
|
type WorkspaceSidebarContext,
|
||||||
} from "./layout/sidebar-workspace"
|
} from "./layout/sidebar-workspace"
|
||||||
import { workspaceOpenState } from "./layout/sidebar-workspace-helpers"
|
|
||||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||||
import { SidebarContent } from "./layout/sidebar-shell"
|
import { SidebarContent } from "./layout/sidebar-shell"
|
||||||
|
|
||||||
@@ -1860,7 +1858,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
setEditor,
|
setEditor,
|
||||||
InlineEditor,
|
InlineEditor,
|
||||||
isBusy,
|
isBusy,
|
||||||
workspaceExpanded: (directory, local) => workspaceOpenState(store.workspaceExpanded, directory, local),
|
workspaceExpanded: (directory, local) => store.workspaceExpanded[directory] ?? local,
|
||||||
setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
|
setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
|
||||||
showResetWorkspaceDialog: (root, directory) =>
|
showResetWorkspaceDialog: (root, directory) =>
|
||||||
dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
|
dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ import {
|
|||||||
parseDeepLink,
|
parseDeepLink,
|
||||||
parseNewSessionDeepLink,
|
parseNewSessionDeepLink,
|
||||||
} from "./deep-links"
|
} from "./deep-links"
|
||||||
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
|
|
||||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
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">) =>
|
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
||||||
({
|
({
|
||||||
@@ -192,12 +198,6 @@ describe("layout workspace helpers", () => {
|
|||||||
expect(result?.id).toBe("root")
|
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", () => {
|
test("formats fallback project display name", () => {
|
||||||
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
|
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
|
||||||
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
|
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
|
||||||
|
|||||||
@@ -54,14 +54,6 @@ export const childMapByParent = (sessions: Session[]) => {
|
|||||||
return map
|
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 }) =>
|
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||||
project.name || getFilename(project.worktree)
|
project.name || getFilename(project.worktree)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
@@ -12,7 +12,6 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
||||||
import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
|
import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
|
||||||
import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
|
|
||||||
|
|
||||||
export type ProjectSidebarContext = {
|
export type ProjectSidebarContext = {
|
||||||
currentDir: Accessor<string>
|
currentDir: Accessor<string>
|
||||||
@@ -277,8 +276,10 @@ export const SortableProject = (props: {
|
|||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sortable = createSortable(props.project.worktree)
|
const sortable = createSortable(props.project.worktree)
|
||||||
const selected = createMemo(() =>
|
const selected = createMemo(
|
||||||
projectSelected(props.ctx.currentDir(), props.project.worktree, props.project.sandboxes),
|
() =>
|
||||||
|
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 workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
||||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
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 preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
||||||
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
||||||
const active = createMemo(() =>
|
const active = createMemo(
|
||||||
projectTileActive({
|
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
|
||||||
menu: state.menu,
|
|
||||||
preview: preview(),
|
|
||||||
open: state.open,
|
|
||||||
overlay: overlay(),
|
|
||||||
hoverProject: props.ctx.hoverProject(),
|
|
||||||
worktree: props.project.worktree,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const sidebarExpanded = (mobile: boolean | undefined, opened: boolean) => !!mobile || opened
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -11,7 +11,6 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
|||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { type LocalProject } from "@/context/layout"
|
import { type LocalProject } from "@/context/layout"
|
||||||
import { sidebarExpanded } from "./sidebar-shell-helpers"
|
|
||||||
|
|
||||||
export const SidebarContent = (props: {
|
export const SidebarContent = (props: {
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
@@ -33,7 +32,7 @@ export const SidebarContent = (props: {
|
|||||||
onOpenHelp: () => void
|
onOpenHelp: () => void
|
||||||
renderPanel: () => JSX.Element
|
renderPanel: () => JSX.Element
|
||||||
}): 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")
|
const placement = () => (props.mobile ? "bottom" : "right")
|
||||||
let panel: HTMLDivElement | undefined
|
let panel: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export const workspaceOpenState = (expanded: Record<string, boolean>, directory: string, local: boolean) =>
|
|
||||||
expanded[directory] ?? local
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -144,8 +144,6 @@ const WorkspaceActions = (props: {
|
|||||||
setMenuOpen: (open: boolean) => void
|
setMenuOpen: (open: boolean) => void
|
||||||
setPendingRename: (value: boolean) => void
|
setPendingRename: (value: boolean) => void
|
||||||
sidebarHovering: Accessor<boolean>
|
sidebarHovering: Accessor<boolean>
|
||||||
mobile?: boolean
|
|
||||||
nav: Accessor<HTMLElement | undefined>
|
|
||||||
touch: Accessor<boolean>
|
touch: Accessor<boolean>
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
workspaceValue: Accessor<string>
|
workspaceValue: Accessor<string>
|
||||||
@@ -340,6 +338,22 @@ export const SortableWorkspace = (props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
|
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) => {
|
const openWrapper = (value: boolean) => {
|
||||||
props.ctx.setWorkspaceExpanded(props.directory, value)
|
props.ctx.setWorkspaceExpanded(props.directory, value)
|
||||||
@@ -379,20 +393,7 @@ export const SortableWorkspace = (props: {
|
|||||||
data-action="workspace-toggle"
|
data-action="workspace-toggle"
|
||||||
data-workspace={base64Encode(props.directory)}
|
data-workspace={base64Encode(props.directory)}
|
||||||
>
|
>
|
||||||
<WorkspaceHeader
|
{header()}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -401,20 +402,7 @@ export const SortableWorkspace = (props: {
|
|||||||
menu.open ? "pr-16" : "pr-2"
|
menu.open ? "pr-16" : "pr-2"
|
||||||
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
||||||
>
|
>
|
||||||
<WorkspaceHeader
|
{header()}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<WorkspaceActions
|
<WorkspaceActions
|
||||||
@@ -426,8 +414,6 @@ export const SortableWorkspace = (props: {
|
|||||||
setMenuOpen={(open) => setMenu("open", open)}
|
setMenuOpen={(open) => setMenu("open", open)}
|
||||||
setPendingRename={(value) => setMenu("pendingRename", value)}
|
setPendingRename={(value) => setMenu("pendingRename", value)}
|
||||||
sidebarHovering={props.ctx.sidebarHovering}
|
sidebarHovering={props.ctx.sidebarHovering}
|
||||||
mobile={props.mobile}
|
|
||||||
nav={props.ctx.nav}
|
|
||||||
touch={touch}
|
touch={touch}
|
||||||
language={language}
|
language={language}
|
||||||
workspaceValue={workspaceValue}
|
workspaceValue={workspaceValue}
|
||||||
@@ -490,44 +476,18 @@ export const LocalWorkspace = (props: {
|
|||||||
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
||||||
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
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">
|
<WorkspaceSessionList
|
||||||
<Show when={loading()}>
|
slug={slug}
|
||||||
<SessionSkeleton />
|
mobile={props.mobile}
|
||||||
</Show>
|
ctx={props.ctx}
|
||||||
<For each={sessions()}>
|
showNew={() => false}
|
||||||
{(session) => (
|
loading={loading}
|
||||||
<SessionItem
|
sessions={sessions}
|
||||||
session={session}
|
children={children}
|
||||||
slug={slug()}
|
hasMore={hasMore}
|
||||||
mobile={props.mobile}
|
loadMore={loadMore}
|
||||||
children={children()}
|
language={language}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export { SessionComposerRegion } from "./session-composer-region"
|
export { SessionComposerRegion } from "./session-composer-region"
|
||||||
export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
|
export { createSessionComposerState } from "./session-composer-state"
|
||||||
export type { SessionComposerState } from "./session-composer-state"
|
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
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"
|
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||||
|
|
||||||
const session = (input: { id: string; parentID?: string }) =>
|
const session = (input: { id: string; parentID?: string }) =>
|
||||||
|
|||||||
@@ -8,30 +8,21 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { todoState } from "./session-composer-helpers"
|
|
||||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||||
|
|
||||||
const idle = { type: "idle" as const }
|
export const todoState = (input: {
|
||||||
|
count: number
|
||||||
export function createSessionComposerBlocked() {
|
done: boolean
|
||||||
const params = useParams()
|
live: boolean
|
||||||
const permission = usePermission()
|
}): "hide" | "clear" | "open" | "close" => {
|
||||||
const sdk = useSDK()
|
if (input.count === 0) return "hide"
|
||||||
const sync = useSync()
|
if (!input.live) return "clear"
|
||||||
const permissionRequest = createMemo(() =>
|
if (!input.done) return "open"
|
||||||
sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => {
|
return "close"
|
||||||
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()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const idle = { type: "idle" as const }
|
||||||
|
|
||||||
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
|
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|||||||
@@ -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"
|
import { createStore } from "solid-js/store"
|
||||||
|
|
||||||
export const focusTerminalById = (id: string) => {
|
export const focusTerminalById = (id: string) => {
|
||||||
@@ -117,57 +117,3 @@ export const createSizing = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Sizing = ReturnType<typeof 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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")}`
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -19,7 +19,6 @@ import { showToast } from "@opencode-ai/ui/toast"
|
|||||||
import { findLast } from "@opencode-ai/util/array"
|
import { findLast } from "@opencode-ai/util/array"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
|
|
||||||
|
|
||||||
export type SessionCommandContext = {
|
export type SessionCommandContext = {
|
||||||
navigateMessageByOffset: (offset: number) => void
|
navigateMessageByOffset: (offset: number) => void
|
||||||
@@ -84,6 +83,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
prompt.context.add({ type: "file", path, selection, preview })
|
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 navigateMessageByOffset = actions.navigateMessageByOffset
|
||||||
const setActiveMessage = actions.setActiveMessage
|
const setActiveMessage = actions.setActiveMessage
|
||||||
const focusInput = actions.focusInput
|
const focusInput = actions.focusInput
|
||||||
@@ -136,11 +143,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
title: language.t("command.context.addSelection"),
|
title: language.t("command.context.addSelection"),
|
||||||
description: language.t("command.context.addSelection.description"),
|
description: language.t("command.context.addSelection.description"),
|
||||||
keybind: "mod+shift+l",
|
keybind: "mod+shift+l",
|
||||||
disabled: !canAddSelectionContext({
|
disabled: !canAddSelectionContext(),
|
||||||
active: tabs().active(),
|
|
||||||
pathFromTab: file.pathFromTab,
|
|
||||||
selectedLines: file.selectedLines,
|
|
||||||
}),
|
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
const active = tabs().active()
|
const active = tabs().active()
|
||||||
if (!active) return
|
if (!active) return
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { useLocation, useNavigate } from "@solidjs/router"
|
|||||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { messageIdFromHash } from "./message-id-from-hash"
|
import { messageIdFromHash } from "./message-id-from-hash"
|
||||||
|
|
||||||
export { messageIdFromHash } from "./message-id-from-hash"
|
|
||||||
|
|
||||||
export const useSessionHashScroll = (input: {
|
export const useSessionHashScroll = (input: {
|
||||||
sessionKey: () => string
|
sessionKey: () => string
|
||||||
sessionID: () => string | undefined
|
sessionID: () => string | undefined
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./dom"
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user