mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-29 21:33:54 +00:00
wip: desktop work
This commit is contained in:
parent
8699e896e6
commit
cc955098cd
31
AGENTS.md
31
AGENTS.md
@ -14,3 +14,34 @@
|
||||
## Debugging
|
||||
|
||||
- To test opencode in the `packages/opencode` directory you can run `bun dev`
|
||||
|
||||
## Tool Calling
|
||||
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environnement:
|
||||
|
||||
json
|
||||
{
|
||||
"recipient_name": "multi_tool_use.parallel",
|
||||
"parameters": {
|
||||
"tool_uses": [
|
||||
{
|
||||
"recipient_name": "functions.read",
|
||||
"parameters": {
|
||||
"filePath": "path/to/file.tsx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"recipient_name": "functions.read",
|
||||
"parameters": {
|
||||
"filePath": "path/to/file.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"recipient_name": "functions.read",
|
||||
"parameters": {
|
||||
"filePath": "path/to/file.md"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "shiki"
|
||||
import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js"
|
||||
import { useLocal, useShiki } from "@/context"
|
||||
import type { TextSelection } from "@/context/local"
|
||||
import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils"
|
||||
|
||||
type DefinedSelection = Exclude<TextSelection, undefined>
|
||||
|
||||
interface Props extends ComponentProps<"div"> {
|
||||
code: string
|
||||
path: string
|
||||
@ -21,16 +24,65 @@ export function Code(props: Props) {
|
||||
let container: HTMLDivElement | undefined
|
||||
let isProgrammaticSelection = false
|
||||
|
||||
const [html] = createResource(async () => {
|
||||
const ranges = createMemo<DefinedSelection[]>(() => {
|
||||
const items = ctx.context.all() as Array<{ type: "file"; path: string; selection?: DefinedSelection }>
|
||||
const result: DefinedSelection[] = []
|
||||
for (const item of items) {
|
||||
if (item.path !== local.path) continue
|
||||
const selection = item.selection
|
||||
if (!selection) continue
|
||||
result.push(selection)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const createLineNumberTransformer = (selections: DefinedSelection[]): ShikiTransformer => {
|
||||
const highlighted = new Set<number>()
|
||||
for (const selection of selections) {
|
||||
const startLine = selection.startLine
|
||||
const endLine = selection.endLine
|
||||
const start = Math.max(1, Math.min(startLine, endLine))
|
||||
const end = Math.max(start, Math.max(startLine, endLine))
|
||||
const count = end - start + 1
|
||||
if (count <= 0) continue
|
||||
const values = Array.from({ length: count }, (_, index) => start + index)
|
||||
for (const value of values) highlighted.add(value)
|
||||
}
|
||||
return {
|
||||
name: "line-number-highlight",
|
||||
line(node, index) {
|
||||
if (!highlighted.has(index)) return
|
||||
this.addClassToHast(node, "line-number-highlight")
|
||||
const children = node.children
|
||||
if (!Array.isArray(children)) return
|
||||
for (const child of children) {
|
||||
if (!child || typeof child !== "object") continue
|
||||
const element = child as { type?: string; properties?: { className?: string[] } }
|
||||
if (element.type !== "element") continue
|
||||
const className = element.properties?.className
|
||||
if (!Array.isArray(className)) continue
|
||||
const matches = className.includes("diff-oldln") || className.includes("diff-newln")
|
||||
if (!matches) continue
|
||||
if (className.includes("line-number-highlight")) continue
|
||||
className.push("line-number-highlight")
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const [html] = createResource(
|
||||
() => ranges(),
|
||||
async (activeRanges) => {
|
||||
if (!highlighter.getLoadedLanguages().includes(lang())) {
|
||||
await highlighter.loadLanguage(lang() as BundledLanguage)
|
||||
}
|
||||
return highlighter.codeToHtml(local.code || "", {
|
||||
lang: lang() && lang() in bundledLanguages ? lang() : "text",
|
||||
theme: "opencode",
|
||||
transformers: [transformerUnifiedDiff(), transformerDiffGroups()],
|
||||
transformers: [transformerUnifiedDiff(), transformerDiffGroups(), createLineNumberTransformer(activeRanges)],
|
||||
}) as string
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
if (!container) return
|
||||
@ -283,7 +335,7 @@ export function Code(props: Props) {
|
||||
[&]:[counter-reset:line]
|
||||
[&_pre]:focus-visible:outline-none
|
||||
[&_pre]:overflow-x-auto [&_pre]:no-scrollbar
|
||||
[&_code]:min-w-full [&_code]:inline-block [&_code]:pb-40
|
||||
[&_code]:min-w-full [&_code]:inline-block
|
||||
[&_.tab]:relative
|
||||
[&_.tab::before]:content['⇥']
|
||||
[&_.tab::before]:absolute
|
||||
@ -303,6 +355,9 @@ export function Code(props: Props) {
|
||||
[&_.line::before]:select-none
|
||||
[&_.line::before]:[counter-increment:line]
|
||||
[&_.line::before]:content-[counter(line)]
|
||||
[&_.line-number-highlight]:bg-accent/20
|
||||
[&_.line-number-highlight::before]:bg-accent/40!
|
||||
[&_.line-number-highlight::before]:text-background-panel!
|
||||
[&_code.code-diff_.line::before]:content-['']
|
||||
[&_code.code-diff_.line::before]:w-0
|
||||
[&_code.code-diff_.line::before]:pr-0
|
||||
|
||||
381
packages/app/src/components/editor-pane.tsx
Normal file
381
packages/app/src/components/editor-pane.tsx
Normal file
@ -0,0 +1,381 @@
|
||||
import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js"
|
||||
import { Tabs } from "@/ui/tabs"
|
||||
import { FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
createSortable,
|
||||
useDragDropContext,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||
import type { LocalFile } from "@/context/local"
|
||||
import { Code } from "@/components/code"
|
||||
import PromptForm from "@/components/prompt-form"
|
||||
import { useLocal, useSDK, useSync } from "@/context"
|
||||
import { getFilename } from "@/utils"
|
||||
import type { JSX } from "solid-js"
|
||||
|
||||
interface EditorPaneProps {
|
||||
layoutKey: string
|
||||
timelinePane: string
|
||||
onFileClick: (file: LocalFile) => void
|
||||
onOpenModelSelect: () => void
|
||||
onInputRefChange: (element: HTMLTextAreaElement | null) => void
|
||||
}
|
||||
|
||||
export default function EditorPane(props: EditorPaneProps): JSX.Element {
|
||||
const [localProps] = splitProps(props, [
|
||||
"layoutKey",
|
||||
"timelinePane",
|
||||
"onFileClick",
|
||||
"onOpenModelSelect",
|
||||
"onInputRefChange",
|
||||
])
|
||||
const local = useLocal()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
|
||||
|
||||
const navigateChange = (dir: 1 | -1) => {
|
||||
const active = local.file.active()
|
||||
if (!active) return
|
||||
const current = local.file.changeIndex(active.path)
|
||||
const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
|
||||
local.file.setChangeIndex(active.path, next)
|
||||
}
|
||||
|
||||
const handleTabChange = (path: string) => {
|
||||
local.file.open(path)
|
||||
}
|
||||
|
||||
const handleTabClose = (file: LocalFile) => {
|
||||
local.file.close(file.path)
|
||||
}
|
||||
|
||||
const handlePromptSubmit = async (prompt: string) => {
|
||||
const existingSession = local.layout.visible(localProps.layoutKey, localProps.timelinePane)
|
||||
? local.session.active()
|
||||
: undefined
|
||||
let session = existingSession
|
||||
if (!session) {
|
||||
const created = await sdk.session.create()
|
||||
session = created.data ?? undefined
|
||||
}
|
||||
if (!session) return
|
||||
local.session.setActive(session.id)
|
||||
local.layout.show(localProps.layoutKey, localProps.timelinePane)
|
||||
|
||||
await sdk.session.prompt({
|
||||
path: { id: session.id },
|
||||
body: {
|
||||
agent: local.agent.current()!.name,
|
||||
model: {
|
||||
modelID: local.model.current()!.id,
|
||||
providerID: local.model.current()!.provider.id,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: prompt,
|
||||
},
|
||||
...(local.context.active()
|
||||
? [
|
||||
{
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
url: `file://${local.context.active()!.absolute}`,
|
||||
filename: local.context.active()!.name,
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: "@" + local.context.active()!.name,
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
path: local.context.active()!.absolute,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...local.context.all().flatMap((file) => [
|
||||
{
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
url: `file://${sync.absolute(file.path)}${file.selection ? `?start=${file.selection.startLine}&end=${file.selection.endLine}` : ""}`,
|
||||
filename: getFilename(file.path),
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: "@" + getFilename(file.path),
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
path: sync.absolute(file.path),
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragStart = (event: unknown) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
setActiveItem(id)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const currentFiles = local.file.opened().map((file) => file.path)
|
||||
const fromIndex = currentFiles.indexOf(draggable.id.toString())
|
||||
const toIndex = currentFiles.indexOf(droppable.id.toString())
|
||||
if (fromIndex !== toIndex) {
|
||||
local.file.move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setActiveItem(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative flex h-full flex-col">
|
||||
<Logo size={64} variant="ornate" class="absolute top-2/5 left-1/2 transform -translate-x-1/2 -translate-y-1/2" />
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs
|
||||
class="relative grow w-full flex flex-col h-full"
|
||||
value={local.file.active()?.path}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List class="grow">
|
||||
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => (
|
||||
<SortableTab file={file} onTabClick={localProps.onFileClick} onTabClose={handleTabClose} />
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</Tabs.List>
|
||||
<div class="shrink-0 h-full flex items-center gap-1 px-2 border-b border-border-subtle/40">
|
||||
<Show when={local.file.active() && local.file.active()!.content?.diff}>
|
||||
{(() => {
|
||||
const activeFile = local.file.active()!
|
||||
const view = local.file.view(activeFile.path)
|
||||
return (
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={view !== "raw"}>
|
||||
<div class="mr-1 flex items-center gap-1">
|
||||
<Tooltip value="Previous change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}>
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Next change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}>
|
||||
<Icon name="arrow-down" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Tooltip value="Raw" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "raw",
|
||||
"text-text-muted/70": view !== "raw",
|
||||
"bg-background-element": view === "raw",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "raw")}
|
||||
>
|
||||
<Icon name="file-text" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Unified diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-unified",
|
||||
"text-text-muted/70": view !== "diff-unified",
|
||||
"bg-background-element": view === "diff-unified",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
|
||||
>
|
||||
<Icon name="checklist" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Split diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-split",
|
||||
"text-text-muted/70": view !== "diff-split",
|
||||
"bg-background-element": view === "diff-split",
|
||||
}}
|
||||
onClick={() => local.file.setView(activeFile.path, "diff-split")}
|
||||
>
|
||||
<Icon name="columns" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Show>
|
||||
<Tooltip
|
||||
value={local.layout.visible(localProps.layoutKey, localProps.timelinePane) ? "Close pane" : "Open pane"}
|
||||
placement="bottom"
|
||||
>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => local.layout.toggle(localProps.layoutKey, localProps.timelinePane)}
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
local.layout.visible(localProps.layoutKey, localProps.timelinePane) ? "close-pane" : "open-pane"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => (
|
||||
<Tabs.Content value={file.path} class="grow h-full pt-1 select-text">
|
||||
{(() => {
|
||||
const view = local.file.view(file.path)
|
||||
const showRaw = view === "raw" || !file.content?.diff
|
||||
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
|
||||
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
|
||||
})()}
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</For>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
{(() => {
|
||||
const id = activeItem()
|
||||
if (!id) return null
|
||||
const draggedFile = local.file.node(id)
|
||||
if (!draggedFile) return null
|
||||
return (
|
||||
<div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
|
||||
<TabVisual file={draggedFile} />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<PromptForm
|
||||
class="peer/editor absolute inset-x-4 z-50 flex items-center justify-center"
|
||||
classList={{
|
||||
"bottom-8": !!local.file.active(),
|
||||
"bottom-3/8": local.file.active() === undefined,
|
||||
}}
|
||||
onSubmit={handlePromptSubmit}
|
||||
onOpenModelSelect={localProps.onOpenModelSelect}
|
||||
onInputRefChange={(element) => localProps.onInputRefChange(element ?? null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TabVisual(props: { file: LocalFile }): JSX.Element {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FileIcon node={props.file} class="" />
|
||||
<span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}>
|
||||
{props.file.name}
|
||||
</span>
|
||||
<span class="text-xs opacity-70">
|
||||
<Switch>
|
||||
<Match when={props.file.status?.status === "modified"}>
|
||||
<span class="text-primary">M</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "added"}>
|
||||
<span class="text-success">A</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "deleted"}>
|
||||
<span class="text-error">D</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableTab(props: {
|
||||
file: LocalFile
|
||||
onTabClick: (file: LocalFile) => void
|
||||
onTabClose: (file: LocalFile) => void
|
||||
}): JSX.Element {
|
||||
const sortable = createSortable(props.file.path)
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
||||
<Tooltip value={props.file.path} placement="bottom">
|
||||
<div class="relative">
|
||||
<Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
|
||||
<TabVisual file={props.file} />
|
||||
</Tabs.Trigger>
|
||||
<IconButton
|
||||
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConstrainDragYAxis(): JSX.Element {
|
||||
const context = useDragDropContext()
|
||||
if (!context) return <></>
|
||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||
const transformer: Transformer = {
|
||||
id: "constrain-y-axis",
|
||||
order: 100,
|
||||
callback: (transform) => ({ ...transform, y: 0 }),
|
||||
}
|
||||
onDragStart((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
addTransformer("draggables", id, transformer)
|
||||
})
|
||||
onDragEnd((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
removeTransformer("draggables", id, transformer.id)
|
||||
})
|
||||
return <></>
|
||||
}
|
||||
|
||||
const getDraggableId = (event: unknown): string | undefined => {
|
||||
if (typeof event !== "object" || event === null) return undefined
|
||||
if (!("draggable" in event)) return undefined
|
||||
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
||||
if (!draggable) return undefined
|
||||
return typeof draggable.id === "string" ? draggable.id : undefined
|
||||
}
|
||||
@ -23,6 +23,30 @@ export default function FileTree(props: {
|
||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||
}}
|
||||
style={`padding-left: ${level * 10}px`}
|
||||
draggable={true}
|
||||
onDragStart={(e: any) => {
|
||||
const evt = e as globalThis.DragEvent
|
||||
evt.dataTransfer!.effectAllowed = "copy"
|
||||
evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`)
|
||||
|
||||
// Create custom drag image without margins
|
||||
const dragImage = document.createElement("div")
|
||||
dragImage.className =
|
||||
"flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1"
|
||||
dragImage.style.position = "absolute"
|
||||
dragImage.style.top = "-1000px"
|
||||
|
||||
// Copy only the icon and text content without padding
|
||||
const icon = e.currentTarget.querySelector("svg")
|
||||
const text = e.currentTarget.querySelector("span")
|
||||
if (icon && text) {
|
||||
dragImage.innerHTML = icon.outerHTML + text.outerHTML
|
||||
}
|
||||
|
||||
document.body.appendChild(dragImage)
|
||||
evt.dataTransfer!.setDragImage(dragImage, 0, 12)
|
||||
setTimeout(() => document.body.removeChild(dragImage), 0)
|
||||
}}
|
||||
{...p}
|
||||
>
|
||||
{p.children}
|
||||
@ -51,6 +75,7 @@ export default function FileTree(props: {
|
||||
<Switch>
|
||||
<Match when={node.type === "directory"}>
|
||||
<Collapsible
|
||||
class="w-full"
|
||||
forceMount={false}
|
||||
open={local.file.node(node.path)?.expanded}
|
||||
onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
|
||||
|
||||
295
packages/app/src/components/prompt-form.tsx
Normal file
295
packages/app/src/components/prompt-form.tsx
Normal file
@ -0,0 +1,295 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { Button, FileIcon, Icon, IconButton, Tooltip } from "@/ui"
|
||||
import { Select } from "@/components/select"
|
||||
import { useLocal } from "@/context"
|
||||
import type { FileContext, LocalFile } from "@/context/local"
|
||||
import { getFilename } from "@/utils"
|
||||
import { createSpeechRecognition } from "@/utils/speech"
|
||||
|
||||
interface PromptFormProps {
|
||||
class?: string
|
||||
classList?: Record<string, boolean>
|
||||
onSubmit: (prompt: string) => Promise<void> | void
|
||||
onOpenModelSelect: () => void
|
||||
onInputRefChange?: (element: HTMLTextAreaElement | undefined) => void
|
||||
}
|
||||
|
||||
export default function PromptForm(props: PromptFormProps) {
|
||||
const local = useLocal()
|
||||
|
||||
const [prompt, setPrompt] = createSignal("")
|
||||
const [isDragOver, setIsDragOver] = createSignal(false)
|
||||
|
||||
const placeholderText = "Start typing or speaking..."
|
||||
|
||||
const {
|
||||
isSupported,
|
||||
isRecording,
|
||||
interim: interimTranscript,
|
||||
start: startSpeech,
|
||||
stop: stopSpeech,
|
||||
} = createSpeechRecognition({
|
||||
onFinal: (text) => setPrompt((prev) => (prev && !prev.endsWith(" ") ? prev + " " : prev) + text),
|
||||
})
|
||||
|
||||
let inputRef: HTMLTextAreaElement | undefined = undefined
|
||||
let overlayContainerRef: HTMLDivElement | undefined = undefined
|
||||
let shouldAutoScroll = true
|
||||
|
||||
const promptContent = createMemo(() => {
|
||||
const base = prompt() || ""
|
||||
const interim = isRecording() ? interimTranscript() : ""
|
||||
if (!base && !interim) {
|
||||
return <span class="text-text-muted/70">{placeholderText}</span>
|
||||
}
|
||||
const needsSpace = base && interim && !base.endsWith(" ") && !interim.startsWith(" ")
|
||||
return (
|
||||
<>
|
||||
<span class="text-text">{base}</span>
|
||||
{interim && (
|
||||
<span class="text-text-muted/60 italic">
|
||||
{needsSpace ? " " : ""}
|
||||
{interim}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
prompt()
|
||||
interimTranscript()
|
||||
queueMicrotask(() => {
|
||||
if (!inputRef) return
|
||||
if (!overlayContainerRef) return
|
||||
if (!shouldAutoScroll) {
|
||||
overlayContainerRef.scrollTop = inputRef.scrollTop
|
||||
return
|
||||
}
|
||||
scrollPromptToEnd()
|
||||
})
|
||||
})
|
||||
|
||||
const handlePromptKeyDown = (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => {
|
||||
if (event.isComposing) return
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
inputRef?.form?.requestSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePromptScroll = (event: Event & { currentTarget: HTMLTextAreaElement }) => {
|
||||
const target = event.currentTarget
|
||||
shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4
|
||||
if (overlayContainerRef) overlayContainerRef.scrollTop = target.scrollTop
|
||||
}
|
||||
|
||||
const scrollPromptToEnd = () => {
|
||||
if (!inputRef) return
|
||||
const maxInputScroll = inputRef.scrollHeight - inputRef.clientHeight
|
||||
const next = maxInputScroll > 0 ? maxInputScroll : 0
|
||||
inputRef.scrollTop = next
|
||||
if (overlayContainerRef) overlayContainerRef.scrollTop = next
|
||||
shouldAutoScroll = true
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: SubmitEvent) => {
|
||||
event.preventDefault()
|
||||
const currentPrompt = prompt()
|
||||
setPrompt("")
|
||||
shouldAutoScroll = true
|
||||
if (overlayContainerRef) overlayContainerRef.scrollTop = 0
|
||||
if (inputRef) {
|
||||
inputRef.scrollTop = 0
|
||||
inputRef.blur()
|
||||
}
|
||||
|
||||
await props.onSubmit(currentPrompt)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
props.onInputRefChange?.(undefined)
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class={props.class} classList={props.classList}>
|
||||
<div
|
||||
class="w-full max-w-xl min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
|
||||
flex flex-col gap-1
|
||||
bg-gradient-to-b from-background-panel/90 to-background/90
|
||||
ring-1 ring-border-active/50 border border-transparent
|
||||
focus-within:ring-2 focus-within:ring-primary/40 focus-within:border-primary
|
||||
transition-all duration-200"
|
||||
classList={{
|
||||
"shadow-[0_0_33px_rgba(0,0,0,0.8)]": !!local.file.active(),
|
||||
"ring-2 ring-primary/60 bg-primary/5": isDragOver(),
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
const evt = event as unknown as globalThis.DragEvent
|
||||
if (evt.dataTransfer?.types.includes("text/plain")) {
|
||||
evt.preventDefault()
|
||||
setIsDragOver(true)
|
||||
}
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (event.currentTarget === event.target) {
|
||||
setIsDragOver(false)
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
const evt = event as unknown as globalThis.DragEvent
|
||||
if (evt.dataTransfer?.types.includes("text/plain")) {
|
||||
evt.preventDefault()
|
||||
evt.dataTransfer.dropEffect = "copy"
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const evt = event as unknown as globalThis.DragEvent
|
||||
evt.preventDefault()
|
||||
setIsDragOver(false)
|
||||
|
||||
const data = evt.dataTransfer?.getData("text/plain")
|
||||
if (data && data.startsWith("file:")) {
|
||||
const filePath = data.slice(5)
|
||||
const fileNode = local.file.node(filePath)
|
||||
if (fileNode) {
|
||||
local.context.add({
|
||||
type: "file",
|
||||
path: filePath,
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Show when={local.context.all().length > 0 || local.context.active()}>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Show when={local.context.active()}>
|
||||
<ActiveTabContextTag file={local.context.active()!} onClose={() => local.context.removeActive()} />
|
||||
</Show>
|
||||
<For each={local.context.all()}>
|
||||
{(file) => <FileTag file={file} onClose={() => local.context.remove(file.key)} />}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
ref={(element) => {
|
||||
inputRef = element ?? undefined
|
||||
props.onInputRefChange?.(inputRef)
|
||||
}}
|
||||
value={prompt()}
|
||||
onInput={(event) => setPrompt(event.currentTarget.value)}
|
||||
onKeyDown={handlePromptKeyDown}
|
||||
onScroll={handlePromptScroll}
|
||||
placeholder={placeholderText}
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
class="relative w-full h-20 rounded-md px-0.5 resize-none overflow-y-auto
|
||||
bg-transparent text-transparent caret-text font-light text-base
|
||||
leading-relaxed focus:outline-none selection:bg-primary/20"
|
||||
></textarea>
|
||||
<div
|
||||
ref={(element) => {
|
||||
overlayContainerRef = element ?? undefined
|
||||
}}
|
||||
class="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<div class="px-0.5 text-base font-light leading-relaxed whitespace-pre-wrap text-left text-text">
|
||||
{promptContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-xs text-text-muted">
|
||||
<div class="flex gap-2 items-center">
|
||||
<Select
|
||||
options={local.agent.list().map((agent) => agent.name)}
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
class="uppercase"
|
||||
/>
|
||||
<Button onClick={() => props.onOpenModelSelect()}>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<Icon name="chevron-down" size={24} class="text-text-muted" />
|
||||
</Button>
|
||||
<span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span>
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<Show when={isSupported()}>
|
||||
<Tooltip value={isRecording() ? "Stop voice input" : "Start voice input"} placement="top">
|
||||
<IconButton
|
||||
onClick={async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
if (isRecording()) {
|
||||
stopSpeech()
|
||||
} else {
|
||||
startSpeech()
|
||||
}
|
||||
inputRef?.focus()
|
||||
}}
|
||||
classList={{
|
||||
"text-text-muted": !isRecording(),
|
||||
"text-error! animate-pulse": isRecording(),
|
||||
}}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon name="mic" size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost">
|
||||
<Icon name="photo" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
class="text-background-panel! bg-primary rounded-full! hover:bg-primary/90 ml-0.5"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
type="submit"
|
||||
>
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const ActiveTabContextTag = (props: { file: LocalFile; onClose: () => void }) => (
|
||||
<div
|
||||
class="flex items-center bg-background group/tag
|
||||
border border-border-subtle/60 border-dashed
|
||||
rounded-md text-xs text-text-muted"
|
||||
>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}>
|
||||
<Icon name="file" class="group-hover/tag:hidden" size={12} />
|
||||
<Icon name="close" class="hidden group-hover/tag:block" size={12} />
|
||||
</IconButton>
|
||||
<div class="pr-1 flex gap-1 items-center">
|
||||
<span>{getFilename(props.file.path)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const FileTag = (props: { file: FileContext; onClose: () => void }) => (
|
||||
<div
|
||||
class="flex items-center bg-background group/tag
|
||||
border border-border-subtle/60
|
||||
rounded-md text-xs text-text-muted"
|
||||
>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}>
|
||||
<FileIcon node={props.file} class="group-hover/tag:hidden size-3!" />
|
||||
<Icon name="close" class="hidden group-hover/tag:block" size={12} />
|
||||
</IconButton>
|
||||
<div class="pr-1 flex gap-1 items-center">
|
||||
<span>{getFilename(props.file.path)}</span>
|
||||
<Show when={props.file.selection}>
|
||||
<span>
|
||||
({props.file.selection!.startLine}-{props.file.selection!.endLine})
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
217
packages/app/src/components/resizeable-pane.tsx
Normal file
217
packages/app/src/components/resizeable-pane.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import { batch, createContext, createMemo, createSignal, onCleanup, Show, useContext } from "solid-js"
|
||||
import type { ComponentProps, JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context"
|
||||
|
||||
type PaneDefault = number | { size: number; visible?: boolean }
|
||||
|
||||
type LayoutContextValue = {
|
||||
id: string
|
||||
register: (pane: string, options: { min?: number | string; max?: number | string }) => void
|
||||
size: (pane: string) => number
|
||||
visible: (pane: string) => boolean
|
||||
percent: (pane: string) => number
|
||||
next: (pane: string) => string | undefined
|
||||
startDrag: (left: string, right: string | undefined, event: MouseEvent) => void
|
||||
dragging: () => string | undefined
|
||||
}
|
||||
|
||||
const LayoutContext = createContext<LayoutContextValue | undefined>(undefined)
|
||||
|
||||
export interface ResizeableLayoutProps {
|
||||
id: string
|
||||
defaults: Record<string, PaneDefault>
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export interface ResizeablePaneProps {
|
||||
id: string
|
||||
minSize?: number | string
|
||||
maxSize?: number | string
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export function ResizeableLayout(props: ResizeableLayoutProps) {
|
||||
const local = useLocal()
|
||||
const [meta, setMeta] = createStore<Record<string, { min: number; max: number; minPx?: number; maxPx?: number }>>({})
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
let container: HTMLDivElement | undefined
|
||||
|
||||
local.layout.ensure(props.id, props.defaults)
|
||||
|
||||
const order = createMemo(() => local.layout.order(props.id))
|
||||
const visibleOrder = createMemo(() => order().filter((pane) => local.layout.visible(props.id, pane)))
|
||||
const totalVisible = createMemo(() => {
|
||||
const panes = visibleOrder()
|
||||
if (!panes.length) return 0
|
||||
return panes.reduce((total, pane) => total + local.layout.size(props.id, pane), 0)
|
||||
})
|
||||
|
||||
const percent = (pane: string) => {
|
||||
const panes = visibleOrder()
|
||||
if (!panes.length) return 0
|
||||
const total = totalVisible()
|
||||
if (!total) return 100 / panes.length
|
||||
return (local.layout.size(props.id, pane) / total) * 100
|
||||
}
|
||||
|
||||
const nextPane = (pane: string) => {
|
||||
const panes = visibleOrder()
|
||||
const index = panes.indexOf(pane)
|
||||
if (index === -1) return undefined
|
||||
return panes[index + 1]
|
||||
}
|
||||
|
||||
const minMax = (pane: string) => meta[pane] ?? { min: 5, max: 95 }
|
||||
|
||||
const pxToPercent = (px: number, total: number) => (px / total) * 100
|
||||
|
||||
const boundsForPair = (left: string, right: string, total: number) => {
|
||||
const leftMeta = minMax(left)
|
||||
const rightMeta = minMax(right)
|
||||
const containerWidth = container?.getBoundingClientRect().width ?? 0
|
||||
|
||||
let minLeft = leftMeta.min
|
||||
let maxLeft = leftMeta.max
|
||||
let minRight = rightMeta.min
|
||||
let maxRight = rightMeta.max
|
||||
|
||||
if (containerWidth && leftMeta.minPx !== undefined) minLeft = pxToPercent(leftMeta.minPx, containerWidth)
|
||||
if (containerWidth && leftMeta.maxPx !== undefined) maxLeft = pxToPercent(leftMeta.maxPx, containerWidth)
|
||||
if (containerWidth && rightMeta.minPx !== undefined) minRight = pxToPercent(rightMeta.minPx, containerWidth)
|
||||
if (containerWidth && rightMeta.maxPx !== undefined) maxRight = pxToPercent(rightMeta.maxPx, containerWidth)
|
||||
|
||||
const finalMinLeft = Math.max(minLeft, total - maxRight)
|
||||
const finalMaxLeft = Math.min(maxLeft, total - minRight)
|
||||
return {
|
||||
min: Math.min(finalMinLeft, finalMaxLeft),
|
||||
max: Math.max(finalMinLeft, finalMaxLeft),
|
||||
}
|
||||
}
|
||||
|
||||
const setPair = (left: string, right: string, leftSize: number, rightSize: number) => {
|
||||
batch(() => {
|
||||
local.layout.setSize(props.id, left, leftSize)
|
||||
local.layout.setSize(props.id, right, rightSize)
|
||||
})
|
||||
}
|
||||
|
||||
const startDrag = (left: string, right: string | undefined, event: MouseEvent) => {
|
||||
if (!right) return
|
||||
if (!container) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (!rect.width) return
|
||||
event.preventDefault()
|
||||
const startX = event.clientX
|
||||
const startLeft = local.layout.size(props.id, left)
|
||||
const startRight = local.layout.size(props.id, right)
|
||||
const total = startLeft + startRight
|
||||
const bounds = boundsForPair(left, right, total)
|
||||
const move = (moveEvent: MouseEvent) => {
|
||||
const delta = ((moveEvent.clientX - startX) / rect.width) * 100
|
||||
const nextLeft = Math.max(bounds.min, Math.min(bounds.max, startLeft + delta))
|
||||
const nextRight = total - nextLeft
|
||||
setPair(left, right, nextLeft, nextRight)
|
||||
}
|
||||
const stop = () => {
|
||||
setDragging()
|
||||
document.removeEventListener("mousemove", move)
|
||||
document.removeEventListener("mouseup", stop)
|
||||
}
|
||||
setDragging(left)
|
||||
document.addEventListener("mousemove", move)
|
||||
document.addEventListener("mouseup", stop)
|
||||
onCleanup(() => stop())
|
||||
}
|
||||
|
||||
const register = (pane: string, options: { min?: number | string; max?: number | string }) => {
|
||||
let min = 5
|
||||
let max = 95
|
||||
let minPx: number | undefined
|
||||
let maxPx: number | undefined
|
||||
|
||||
if (typeof options.min === "string" && options.min.endsWith("px")) {
|
||||
minPx = parseInt(options.min)
|
||||
min = 0
|
||||
} else if (typeof options.min === "number") {
|
||||
min = options.min
|
||||
}
|
||||
|
||||
if (typeof options.max === "string" && options.max.endsWith("px")) {
|
||||
maxPx = parseInt(options.max)
|
||||
max = 100
|
||||
} else if (typeof options.max === "number") {
|
||||
max = options.max
|
||||
}
|
||||
|
||||
setMeta(pane, () => ({ min, max, minPx, maxPx }))
|
||||
const fallback = props.defaults[pane]
|
||||
local.layout.ensurePane(props.id, pane, fallback ?? { size: min, visible: true })
|
||||
}
|
||||
|
||||
const contextValue: LayoutContextValue = {
|
||||
id: props.id,
|
||||
register,
|
||||
size: (pane) => local.layout.size(props.id, pane),
|
||||
visible: (pane) => local.layout.visible(props.id, pane),
|
||||
percent,
|
||||
next: nextPane,
|
||||
startDrag,
|
||||
dragging,
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={contextValue}>
|
||||
<div
|
||||
ref={(node) => {
|
||||
container = node ?? undefined
|
||||
}}
|
||||
class={props.class ? `relative flex h-full w-full ${props.class}` : "relative flex h-full w-full"}
|
||||
classList={props.classList}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</LayoutContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResizeablePane(props: ResizeablePaneProps) {
|
||||
const context = useContext(LayoutContext)!
|
||||
context.register(props.id, { min: props.minSize, max: props.maxSize })
|
||||
const visible = () => context.visible(props.id)
|
||||
const width = () => context.percent(props.id)
|
||||
const next = () => context.next(props.id)
|
||||
const dragging = () => context.dragging() === props.id
|
||||
|
||||
return (
|
||||
<Show when={visible()}>
|
||||
<div
|
||||
class={props.class ? `relative flex h-full flex-col ${props.class}` : "relative flex h-full flex-col"}
|
||||
classList={props.classList}
|
||||
style={{
|
||||
width: `${width()}%`,
|
||||
flex: `0 0 ${width()}%`,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<Show when={next()}>
|
||||
<div
|
||||
class="absolute top-0 -right-1 h-full w-1.5 cursor-col-resize z-50 group"
|
||||
onMouseDown={(event) => context.startDrag(props.id, next(), event)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-0.5 h-full bg-transparent transition-colors group-hover:bg-border-active": true,
|
||||
"bg-border-active!": dragging(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@ -101,11 +101,7 @@ function EditToolPart(props: { part: ToolPart }) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Code
|
||||
path={state().input["filePath"] as string}
|
||||
code={state().metadata["diff"] as string}
|
||||
class="[&_code]:pb-0!"
|
||||
/>
|
||||
<Code path={state().input["filePath"] as string} code={state().metadata["diff"] as string} />
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
@ -412,7 +408,7 @@ export default function SessionTimeline(props: { session: string; class?: string
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code path="session.json" code={JSON.stringify(session(), null, 2)} class="[&_code]:pb-0!" />
|
||||
<Code path="session.json" code={JSON.stringify(session(), null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
@ -429,15 +425,11 @@ export default function SessionTimeline(props: { session: string; class?: string
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code
|
||||
path={message.id + ".json"}
|
||||
code={JSON.stringify(message, null, 2)}
|
||||
class="[&_code]:pb-0!"
|
||||
/>
|
||||
<Code path={message.id + ".json"} code={JSON.stringify(message, null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
<For each={sync.data.part[message.id]?.filter(valid)}>
|
||||
<For each={sync.data.part[message.id]}>
|
||||
{(part) => (
|
||||
<li>
|
||||
<Collapsible>
|
||||
@ -449,11 +441,7 @@ export default function SessionTimeline(props: { session: string; class?: string
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code
|
||||
path={message.id + "." + part.id + ".json"}
|
||||
code={JSON.stringify(part, null, 2)}
|
||||
class="[&_code]:pb-0!"
|
||||
/>
|
||||
<Code path={message.id + "." + part.id + ".json"} code={JSON.stringify(part, null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
|
||||
@ -25,6 +25,9 @@ export type LocalModel = Omit<Model, "provider"> & {
|
||||
}
|
||||
export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
export type FileContext = { type: "file"; path: string; selection?: TextSelection }
|
||||
export type ContextItem = FileContext
|
||||
|
||||
function init() {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
@ -163,7 +166,16 @@ function init() {
|
||||
}
|
||||
|
||||
const resetNode = (path: string) => {
|
||||
setStore("node", path, undefined!)
|
||||
setStore("node", path, {
|
||||
loaded: undefined,
|
||||
pinned: undefined,
|
||||
content: undefined,
|
||||
selection: undefined,
|
||||
scrollTop: undefined,
|
||||
folded: undefined,
|
||||
view: undefined,
|
||||
selectedChange: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
|
||||
@ -203,6 +215,7 @@ function init() {
|
||||
]
|
||||
})
|
||||
setStore("active", relativePath)
|
||||
context.addActive()
|
||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
||||
if (store.node[relativePath].loaded) return
|
||||
@ -336,52 +349,103 @@ function init() {
|
||||
})()
|
||||
|
||||
const layout = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
rightPane: boolean
|
||||
leftWidth: number
|
||||
rightWidth: number
|
||||
}>({
|
||||
rightPane: false,
|
||||
leftWidth: 200, // Default 50 * 4px (w-50 = 12.5rem = 200px)
|
||||
rightWidth: 320, // Default 80 * 4px (w-80 = 20rem = 320px)
|
||||
})
|
||||
type PaneState = { size: number; visible: boolean }
|
||||
type LayoutState = { panes: Record<string, PaneState>; order: string[] }
|
||||
type PaneDefault = number | { size: number; visible?: boolean }
|
||||
|
||||
const value = localStorage.getItem("layout")
|
||||
if (value) {
|
||||
const v = JSON.parse(value)
|
||||
if (typeof v?.rightPane === "boolean") setStore("rightPane", v.rightPane)
|
||||
if (typeof v?.leftWidth === "number") setStore("leftWidth", Math.max(150, Math.min(400, v.leftWidth)))
|
||||
if (typeof v?.rightWidth === "number") setStore("rightWidth", Math.max(200, Math.min(500, v.rightWidth)))
|
||||
const [store, setStore] = createStore<Record<string, LayoutState>>({})
|
||||
|
||||
const raw = localStorage.getItem("layout")
|
||||
if (raw) {
|
||||
const data = JSON.parse(raw)
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
const first = Object.values(data)[0] as LayoutState
|
||||
if (first && typeof first === "object" && "panes" in first) {
|
||||
setStore(() => data as Record<string, LayoutState>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
localStorage.setItem("layout", JSON.stringify(store))
|
||||
})
|
||||
|
||||
const normalize = (value: PaneDefault): PaneState => {
|
||||
if (typeof value === "number") return { size: value, visible: true }
|
||||
return { size: value.size, visible: value.visible ?? true }
|
||||
}
|
||||
|
||||
const ensure = (id: string, defaults: Record<string, PaneDefault>) => {
|
||||
const entries = Object.entries(defaults)
|
||||
if (!entries.length) return
|
||||
setStore(id, (current) => {
|
||||
if (current) return current
|
||||
return {
|
||||
rightPane() {
|
||||
return store.rightPane
|
||||
},
|
||||
leftWidth() {
|
||||
return store.leftWidth
|
||||
},
|
||||
rightWidth() {
|
||||
return store.rightWidth
|
||||
},
|
||||
toggleRightPane() {
|
||||
setStore("rightPane", (x) => !x)
|
||||
},
|
||||
openRightPane() {
|
||||
setStore("rightPane", true)
|
||||
},
|
||||
closeRightPane() {
|
||||
setStore("rightPane", false)
|
||||
},
|
||||
setLeftWidth(width: number) {
|
||||
setStore("leftWidth", Math.max(150, Math.min(400, width)))
|
||||
},
|
||||
setRightWidth(width: number) {
|
||||
setStore("rightWidth", Math.max(200, Math.min(500, width)))
|
||||
},
|
||||
panes: Object.fromEntries(entries.map(([pane, config]) => [pane, normalize(config)])),
|
||||
order: entries.map(([pane]) => pane),
|
||||
}
|
||||
})
|
||||
for (const [pane, config] of entries) {
|
||||
if (!store[id]?.panes[pane]) {
|
||||
setStore(id, "panes", pane, () => normalize(config))
|
||||
}
|
||||
if (!(store[id]?.order ?? []).includes(pane)) {
|
||||
setStore(id, "order", (list) => [...list, pane])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ensurePane = (id: string, pane: string, fallback?: PaneDefault) => {
|
||||
if (!store[id]) {
|
||||
const value = normalize(fallback ?? { size: 0, visible: true })
|
||||
setStore(id, () => ({
|
||||
panes: { [pane]: value },
|
||||
order: [pane],
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (!store[id].panes[pane]) {
|
||||
const value = normalize(fallback ?? { size: 0, visible: true })
|
||||
setStore(id, "panes", pane, () => value)
|
||||
}
|
||||
if (!store[id].order.includes(pane)) {
|
||||
setStore(id, "order", (list) => [...list, pane])
|
||||
}
|
||||
}
|
||||
|
||||
const size = (id: string, pane: string) => store[id]?.panes[pane]?.size ?? 0
|
||||
const visible = (id: string, pane: string) => store[id]?.panes[pane]?.visible ?? false
|
||||
|
||||
const setSize = (id: string, pane: string, value: number) => {
|
||||
if (!store[id]?.panes[pane]) return
|
||||
const next = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
|
||||
setStore(id, "panes", pane, "size", next)
|
||||
}
|
||||
|
||||
const setVisible = (id: string, pane: string, value: boolean) => {
|
||||
if (!store[id]?.panes[pane]) return
|
||||
setStore(id, "panes", pane, "visible", value)
|
||||
}
|
||||
|
||||
const toggle = (id: string, pane: string) => {
|
||||
setVisible(id, pane, !visible(id, pane))
|
||||
}
|
||||
|
||||
const show = (id: string, pane: string) => setVisible(id, pane, true)
|
||||
const hide = (id: string, pane: string) => setVisible(id, pane, false)
|
||||
const order = (id: string) => store[id]?.order ?? []
|
||||
|
||||
return {
|
||||
ensure,
|
||||
ensurePane,
|
||||
size,
|
||||
visible,
|
||||
setSize,
|
||||
setVisible,
|
||||
toggle,
|
||||
show,
|
||||
hide,
|
||||
order,
|
||||
}
|
||||
})()
|
||||
|
||||
@ -406,12 +470,51 @@ function init() {
|
||||
}
|
||||
})()
|
||||
|
||||
const context = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
activeTab: boolean
|
||||
items: (ContextItem & { key: string })[]
|
||||
}>({
|
||||
activeTab: true,
|
||||
items: [],
|
||||
})
|
||||
|
||||
return {
|
||||
all() {
|
||||
return store.items
|
||||
},
|
||||
active() {
|
||||
return store.activeTab ? file.active() : undefined
|
||||
},
|
||||
addActive() {
|
||||
setStore("activeTab", true)
|
||||
},
|
||||
removeActive() {
|
||||
setStore("activeTab", false)
|
||||
},
|
||||
add(item: ContextItem) {
|
||||
let key = item.type
|
||||
switch (item.type) {
|
||||
case "file":
|
||||
key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
|
||||
break
|
||||
}
|
||||
if (store.items.find((x) => x.key === key)) return
|
||||
setStore("items", (x) => [...x, { key, ...item }])
|
||||
},
|
||||
remove(key: string) {
|
||||
setStore("items", (x) => x.filter((x) => x.key !== key))
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const result = {
|
||||
model,
|
||||
agent,
|
||||
file,
|
||||
layout,
|
||||
session,
|
||||
context,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@ -115,6 +115,7 @@ function init() {
|
||||
|
||||
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
|
||||
const sanitize = (text: string) => text.replace(sanitizer(), "")
|
||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||
|
||||
return {
|
||||
data: store,
|
||||
@ -146,6 +147,7 @@ function init() {
|
||||
},
|
||||
},
|
||||
load,
|
||||
absolute,
|
||||
sanitize,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,21 +1,11 @@
|
||||
import { Button, FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
|
||||
import { FileIcon, Icon, IconButton, Tooltip } from "@/ui"
|
||||
import { Tabs } from "@/ui/tabs"
|
||||
import { Select } from "@/components/select"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import EditorPane from "@/components/editor-pane"
|
||||
import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { SelectDialog } from "@/components/select-dialog"
|
||||
import { useLocal, useSDK } from "@/context"
|
||||
import { Code } from "@/components/code"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
createSortable,
|
||||
closestCenter,
|
||||
useDragDropContext,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||
import { useLocal } from "@/context"
|
||||
import { ResizeableLayout, ResizeablePane } from "@/components/resizeable-pane"
|
||||
import type { LocalFile } from "@/context/local"
|
||||
import SessionList from "@/components/session-list"
|
||||
import SessionTimeline from "@/components/session-timeline"
|
||||
@ -23,18 +13,17 @@ import { createStore } from "solid-js/store"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
|
||||
export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const local = useLocal()
|
||||
const [store, setStore] = createStore({
|
||||
clickTimer: undefined as number | undefined,
|
||||
activeItem: undefined as string | undefined,
|
||||
prompt: "",
|
||||
dragging: undefined as "left" | "right" | undefined,
|
||||
modelSelectOpen: false,
|
||||
fileSelectOpen: false,
|
||||
})
|
||||
|
||||
let inputRef: HTMLInputElement | undefined = undefined
|
||||
const layoutKey = "workspace"
|
||||
const timelinePane = "timeline"
|
||||
|
||||
let inputRef: HTMLTextAreaElement | undefined = undefined
|
||||
|
||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||
|
||||
@ -46,54 +35,52 @@ export default function Page() {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.getModifierState(MOD) && e.shiftKey && e.key.toLowerCase() === "p") {
|
||||
e.preventDefault()
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault()
|
||||
// TODO: command palette
|
||||
return
|
||||
}
|
||||
if (e.getModifierState(MOD) && e.key.toLowerCase() === "p") {
|
||||
e.preventDefault()
|
||||
if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault()
|
||||
setStore("fileSelectOpen", true)
|
||||
return
|
||||
}
|
||||
|
||||
const inputFocused = document.activeElement === inputRef
|
||||
if (inputFocused) {
|
||||
if (e.key === "Escape") {
|
||||
const focused = document.activeElement === inputRef
|
||||
if (focused) {
|
||||
if (event.key === "Escape") {
|
||||
inputRef?.blur()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (document.activeElement?.id === "select-filter") {
|
||||
return
|
||||
}
|
||||
|
||||
if (local.file.active()) {
|
||||
if (e.getModifierState(MOD)) {
|
||||
if (e.key.toLowerCase() === "a") {
|
||||
const active = local.file.active()!
|
||||
if (event.key === "Enter" && active.selection) {
|
||||
local.context.add({
|
||||
type: "file",
|
||||
path: active.path,
|
||||
selection: { ...active.selection },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (e.key.toLowerCase() === "c") {
|
||||
|
||||
if (event.getModifierState(MOD)) {
|
||||
if (event.key.toLowerCase() === "a") {
|
||||
return
|
||||
}
|
||||
if (event.key.toLowerCase() === "c") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key.length === 1 && e.key !== "Unidentified") {
|
||||
if (event.key.length === 1 && event.key !== "Unidentified") {
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const navigateChange = (dir: 1 | -1) => {
|
||||
const active = local.file.active()
|
||||
if (!active) return
|
||||
const current = local.file.changeIndex(active.path)
|
||||
const next = current == undefined ? (dir === 1 ? 0 : -1) : current + dir
|
||||
local.file.setChangeIndex(active.path, next)
|
||||
}
|
||||
|
||||
const resetClickTimer = () => {
|
||||
if (!store.clickTimer) return
|
||||
clearTimeout(store.clickTimer)
|
||||
@ -117,132 +104,22 @@ export default function Page() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabChange = (path: string) => {
|
||||
local.file.open(path)
|
||||
}
|
||||
|
||||
const handleTabClose = (file: LocalFile) => {
|
||||
local.file.close(file.path)
|
||||
}
|
||||
|
||||
const onDragStart = (event: any) => {
|
||||
setStore("activeItem", event.draggable.id as string)
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const currentFiles = local.file.opened().map((f) => f.path)
|
||||
const fromIndex = currentFiles.indexOf(draggable.id.toString())
|
||||
const toIndex = currentFiles.indexOf(droppable.id.toString())
|
||||
if (fromIndex !== toIndex) {
|
||||
local.file.move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
setStore("activeItem", undefined)
|
||||
}
|
||||
|
||||
const handleLeftDragStart = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setStore("dragging", "left")
|
||||
const startX = e.clientX
|
||||
const startWidth = local.layout.leftWidth()
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - startX
|
||||
const newWidth = startWidth + deltaX
|
||||
local.layout.setLeftWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setStore("dragging", undefined)
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
const handleRightDragStart = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setStore("dragging", "right")
|
||||
const startX = e.clientX
|
||||
const startWidth = local.layout.rightWidth()
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = startX - e.clientX
|
||||
const newWidth = startWidth + deltaX
|
||||
local.layout.setRightWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setStore("dragging", undefined)
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
const prompt = store.prompt
|
||||
setStore("prompt", "")
|
||||
inputRef?.blur()
|
||||
|
||||
const session =
|
||||
(local.layout.rightPane() ? local.session.active() : undefined) ??
|
||||
(await sdk.session.create().then((x) => x.data!))
|
||||
local.session.setActive(session!.id)
|
||||
local.layout.openRightPane()
|
||||
|
||||
const response = await sdk.session.prompt({
|
||||
path: { id: session!.id },
|
||||
body: {
|
||||
agent: local.agent.current()!.name,
|
||||
model: { modelID: local.model.current()!.id, providerID: local.model.current()!.provider.id },
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: prompt,
|
||||
},
|
||||
...local.file
|
||||
.opened()
|
||||
.filter((f) => f.selection || local.file.active()?.path === f.path)
|
||||
.flatMap((f) => [
|
||||
{
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
url: `file://${f.absolute}${f.selection ? `?start=${f.selection.startLine}&end=${f.selection.endLine}` : ""}`,
|
||||
filename: f.name,
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: "@" + f.name,
|
||||
start: 0, // f.start,
|
||||
end: 0, // f.end,
|
||||
},
|
||||
path: f.absolute,
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
console.log("response", response)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<div
|
||||
class="fixed top-0 left-0 h-full border-r border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10"
|
||||
style={`width: ${local.layout.leftWidth()}px`}
|
||||
<ResizeableLayout
|
||||
id={layoutKey}
|
||||
defaults={{
|
||||
explorer: { size: 24, visible: true },
|
||||
editor: { size: 56, visible: true },
|
||||
timeline: { size: 20, visible: false },
|
||||
}}
|
||||
class="h-screen"
|
||||
>
|
||||
<ResizeablePane
|
||||
id="explorer"
|
||||
minSize="150px"
|
||||
maxSize="300px"
|
||||
class="border-r border-border-subtle/30 bg-background z-10 overflow-hidden"
|
||||
>
|
||||
<Tabs class="relative flex flex-col h-full" defaultValue="files">
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
@ -284,23 +161,23 @@ export default function Page() {
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div
|
||||
class="fixed top-0 h-full w-1.5 bg-transparent cursor-col-resize z-50 group"
|
||||
style={`left: ${local.layout.leftWidth()}px`}
|
||||
onMouseDown={(e) => handleLeftDragStart(e)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors": true,
|
||||
"bg-border-active!": store.dragging === "left",
|
||||
</ResizeablePane>
|
||||
<ResizeablePane id="editor" minSize={30} maxSize={80} class="bg-background">
|
||||
<EditorPane
|
||||
layoutKey={layoutKey}
|
||||
timelinePane={timelinePane}
|
||||
onFileClick={handleFileClick}
|
||||
onOpenModelSelect={() => setStore("modelSelectOpen", true)}
|
||||
onInputRefChange={(element: HTMLTextAreaElement | null) => {
|
||||
inputRef = element ?? undefined
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Show when={local.layout.rightPane()}>
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10"
|
||||
style={`width: ${local.layout.rightWidth()}px`}
|
||||
</ResizeablePane>
|
||||
<ResizeablePane
|
||||
id="timeline"
|
||||
minSize={20}
|
||||
maxSize={40}
|
||||
class="border-l border-border-subtle/30 bg-background z-10 overflow-hidden"
|
||||
>
|
||||
<div class="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||
<Show when={local.session.active()} fallback={<SessionList />}>
|
||||
@ -326,216 +203,8 @@ export default function Page() {
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="fixed top-0 h-full w-1.5 bg-transparent cursor-col-resize z-50 group flex justify-end"
|
||||
style={`right: ${local.layout.rightWidth()}px`}
|
||||
onMouseDown={(e) => handleRightDragStart(e)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors": true,
|
||||
"bg-border-active!": store.dragging === "right",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="relative"
|
||||
style={`margin-left: ${local.layout.leftWidth()}px; margin-right: ${local.layout.rightPane() ? local.layout.rightWidth() : 0}px`}
|
||||
>
|
||||
<Logo
|
||||
size={64}
|
||||
variant="ornate"
|
||||
class="absolute top-2/5 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
<DragDropProvider
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs
|
||||
class="relative grow w-full flex flex-col h-screen"
|
||||
value={local.file.active()?.path}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List class="grow">
|
||||
<SortableProvider ids={local.file.opened().map((f) => f.path)}>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</Tabs.List>
|
||||
<div class="shrink-0 h-full flex items-center gap-1 px-2 border-b border-border-subtle/40">
|
||||
<Show when={local.file.active() && local.file.active()!.content?.diff}>
|
||||
{(() => {
|
||||
const f = local.file.active()!
|
||||
const view = local.file.view(f.path)
|
||||
return (
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={view !== "raw"}>
|
||||
<div class="mr-1 flex items-center gap-1">
|
||||
<Tooltip value="Previous change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}>
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Next change" placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}>
|
||||
<Icon name="arrow-down" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Tooltip value="Raw" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "raw",
|
||||
"text-text-muted/70": view !== "raw",
|
||||
"bg-background-element": view === "raw",
|
||||
}}
|
||||
onClick={() => local.file.setView(f.path, "raw")}
|
||||
>
|
||||
<Icon name="file-text" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Unified diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-unified",
|
||||
"text-text-muted/70": view !== "diff-unified",
|
||||
"bg-background-element": view === "diff-unified",
|
||||
}}
|
||||
onClick={() => local.file.setView(f.path, "diff-unified")}
|
||||
>
|
||||
<Icon name="checklist" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip value="Split diff" placement="bottom">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
classList={{
|
||||
"text-text": view === "diff-split",
|
||||
"text-text-muted/70": view !== "diff-split",
|
||||
"bg-background-element": view === "diff-split",
|
||||
}}
|
||||
onClick={() => local.file.setView(f.path, "diff-split")}
|
||||
>
|
||||
<Icon name="columns" size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Show>
|
||||
<Tooltip value={local.layout.rightPane() ? "Close pane" : "Open pane"} placement="bottom">
|
||||
<IconButton size="xs" variant="ghost" onClick={() => local.layout.toggleRightPane()}>
|
||||
<Icon name={local.layout.rightPane() ? "close-pane" : "open-pane"} size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => (
|
||||
<Tabs.Content value={file.path} class="grow h-full pt-1 select-text">
|
||||
{(() => {
|
||||
const view = local.file.view(file.path)
|
||||
const showRaw = view === "raw" || !file.content?.diff
|
||||
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
|
||||
return <Code path={file.path} code={code} />
|
||||
})()}
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</For>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
{store.activeItem &&
|
||||
(() => {
|
||||
const draggedFile = local.file.node(store.activeItem!)
|
||||
return (
|
||||
<div
|
||||
class="relative px-3 h-8 flex items-center
|
||||
text-sm font-medium text-text whitespace-nowrap
|
||||
shrink-0 bg-background-panel
|
||||
border-x border-border-subtle/40 border-b border-b-transparent"
|
||||
>
|
||||
<TabVisual file={draggedFile} />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
class="peer/editor absolute inset-x-4 z-50 flex items-center justify-center"
|
||||
classList={{
|
||||
"bottom-8": !!local.file.active(),
|
||||
"bottom-2/5": local.file.active() === undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-xl min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
|
||||
flex flex-col gap-1
|
||||
bg-gradient-to-b from-background-panel/90 to-background/90
|
||||
ring-1 ring-border-active/50 border border-transparent
|
||||
shadow-[0_0_33px_rgba(0,0,0,0.8)]
|
||||
focus-within:ring-2 focus-within:ring-primary/40 focus-within:border-primary"
|
||||
>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Show when={local.file.active()}>
|
||||
<FileTag
|
||||
default
|
||||
file={local.file.active()!}
|
||||
onClose={() => local.file.close(local.file.active()?.path ?? "")}
|
||||
/>
|
||||
</Show>
|
||||
<For each={local.file.opened().filter((x) => x.selection)}>
|
||||
{(file) => <FileTag file={file} onClose={() => local.file.select(file.path, undefined)} />}
|
||||
</For>
|
||||
</div>
|
||||
<input
|
||||
ref={(el) => (inputRef = el)}
|
||||
type="text"
|
||||
value={store.prompt}
|
||||
onInput={(e) => setStore("prompt", e.currentTarget.value)}
|
||||
placeholder="Placeholder text..."
|
||||
class="w-full p-1 pb-4 text-text font-light placeholder-text-muted/70 text-sm focus:outline-none"
|
||||
/>
|
||||
<div class="flex justify-between items-center text-xs text-text-muted">
|
||||
<div class="flex gap-2 items-center">
|
||||
<Select
|
||||
options={local.agent.list().map((a) => a.name)}
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
class="uppercase"
|
||||
/>
|
||||
<Button onClick={() => setStore("modelSelectOpen", true)}>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<Icon name="chevron-down" size={24} class="text-text-muted" />
|
||||
</Button>
|
||||
<span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span>
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost">
|
||||
<Icon name="photo" size={16} />
|
||||
</IconButton>
|
||||
<IconButton class="text-background-panel! bg-primary rounded-full!" size="xs" variant="ghost">
|
||||
<Icon name="arrow-up" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ResizeablePane>
|
||||
</ResizeableLayout>
|
||||
<Show when={store.modelSelectOpen}>
|
||||
<SelectDialog
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
@ -607,102 +276,3 @@ export default function Page() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TabVisual = (props: { file: LocalFile }) => {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FileIcon node={props.file} class="" />
|
||||
<span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}>
|
||||
{props.file.name}
|
||||
</span>
|
||||
<span class="text-xs opacity-70">
|
||||
<Switch>
|
||||
<Match when={props.file.status?.status === "modified"}>
|
||||
<span class="text-primary">M</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "added"}>
|
||||
<span class="text-success">A</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "deleted"}>
|
||||
<span class="text-error">D</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SortableTab = (props: {
|
||||
file: LocalFile
|
||||
onTabClick: (file: LocalFile) => void
|
||||
onTabClose: (file: LocalFile) => void
|
||||
}) => {
|
||||
const sortable = createSortable(props.file.path)
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
||||
<Tooltip value={props.file.path} placement="bottom">
|
||||
<div class="relative">
|
||||
<Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
|
||||
<TabVisual file={props.file} />
|
||||
</Tabs.Trigger>
|
||||
<IconButton
|
||||
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60
|
||||
peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text
|
||||
peer-data-[selected]/tab:hover:bg-border-subtle
|
||||
hover:opacity-100 peer-hover/tab:opacity-100"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FileTag = (props: { file: LocalFile; default?: boolean; onClose: () => void }) => (
|
||||
<div
|
||||
class="flex items-center bg-background group/tag
|
||||
border border-border-subtle/60 border-dashed
|
||||
rounded-md text-xs text-text-muted"
|
||||
>
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}>
|
||||
<Switch fallback={<FileIcon node={props.file} class="group-hover/tag:hidden size-3!" />}>
|
||||
<Match when={props.default}>
|
||||
<Icon name="file" class="group-hover/tag:hidden" size={12} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="close" class="hidden group-hover/tag:block" size={12} />
|
||||
</IconButton>
|
||||
<div class="pr-1 flex gap-1 items-center">
|
||||
<span>{props.file.name}</span>
|
||||
<Show when={!props.default && props.file.selection}>
|
||||
<span class="">
|
||||
({props.file.selection!.startLine}-{props.file.selection!.endLine})
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ConstrainDragYAxis = () => {
|
||||
const context = useDragDropContext()
|
||||
if (!context) return <></>
|
||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||
const transformer: Transformer = {
|
||||
id: "constrain-y-axis",
|
||||
order: 100,
|
||||
callback: (transform) => ({ ...transform, y: 0 }),
|
||||
}
|
||||
onDragStart((event: any) => {
|
||||
addTransformer("draggables", event.draggable.id, transformer)
|
||||
})
|
||||
onDragEnd((event: any) => {
|
||||
removeTransformer("draggables", event.draggable.id, transformer.id)
|
||||
})
|
||||
return <></>
|
||||
}
|
||||
|
||||
@ -130,6 +130,7 @@ const icons = {
|
||||
search: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 19.25L15.5 15.5M4.75 11C4.75 7.54822 7.54822 4.75 11 4.75C14.4518 4.75 17.25 7.54822 17.25 11C17.25 14.4518 14.4518 17.25 11 17.25C7.54822 17.25 4.75 14.4518 4.75 11Z"></path>',
|
||||
"web-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 8.25v-.5a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v.5m14.5 0H4.75m14.5 0v2m-14.5-2v8a2 2 0 0 0 2 2h2.5m7.743-1.257 2.257 2.257m-4.015-1.53a2.485 2.485 0 1 0 0-4.97 2.485 2.485 0 0 0 0 4.97Z"></path>',
|
||||
loading: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.75v1.5m5.126.624L16 8m3.25 4h-1.5m-.624 5.126-1.768-1.768M12 16.75v2.5m-3.36-3.891-1.768 1.768M7.25 12h-2.5m3.891-3.358L6.874 6.874"></path>',
|
||||
mic: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.75 8C8.75 6.20507 10.2051 4.75 12 4.75C13.7949 4.75 15.25 6.20507 15.25 8V11C15.25 12.7949 13.7949 14.25 12 14.25C10.2051 14.25 8.75 12.7949 8.75 11V8Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.75 12.75C5.75 12.75 6 17.25 12 17.25C18 17.25 18.25 12.75 18.25 12.75"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 17.75V19.25"></path>',
|
||||
} as const
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
|
||||
@ -30,7 +30,9 @@ export function Tooltip(props: TooltipProps) {
|
||||
|
||||
return (
|
||||
<KobalteTooltip forceMount {...others} open={open()} onOpenChange={setOpen}>
|
||||
<KobalteTooltip.Trigger as={"div"}>{c()}</KobalteTooltip.Trigger>
|
||||
<KobalteTooltip.Trigger as={"div"} class="flex items-center">
|
||||
{c()}
|
||||
</KobalteTooltip.Trigger>
|
||||
<KobalteTooltip.Portal>
|
||||
<KobalteTooltip.Content
|
||||
classList={{
|
||||
|
||||
302
packages/app/src/utils/speech.ts
Normal file
302
packages/app/src/utils/speech.ts
Normal file
@ -0,0 +1,302 @@
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
|
||||
// Minimal types to avoid relying on non-standard DOM typings
|
||||
type RecognitionResult = {
|
||||
0: { transcript: string }
|
||||
isFinal: boolean
|
||||
}
|
||||
|
||||
type RecognitionEvent = {
|
||||
results: RecognitionResult[]
|
||||
resultIndex: number
|
||||
}
|
||||
|
||||
interface Recognition {
|
||||
continuous: boolean
|
||||
interimResults: boolean
|
||||
lang: string
|
||||
start: () => void
|
||||
stop: () => void
|
||||
onresult: ((e: RecognitionEvent) => void) | null
|
||||
onerror: ((e: { error: string }) => void) | null
|
||||
onend: (() => void) | null
|
||||
onstart: (() => void) | null
|
||||
}
|
||||
|
||||
const COMMIT_DELAY = 250
|
||||
|
||||
const appendSegment = (base: string, addition: string) => {
|
||||
const trimmed = addition.trim()
|
||||
if (!trimmed) return base
|
||||
if (!base) return trimmed
|
||||
const needsSpace = /\S$/.test(base) && !/^[,.;!?]/.test(trimmed)
|
||||
return `${base}${needsSpace ? " " : ""}${trimmed}`
|
||||
}
|
||||
|
||||
const extractSuffix = (committed: string, hypothesis: string) => {
|
||||
const cleanHypothesis = hypothesis.trim()
|
||||
if (!cleanHypothesis) return ""
|
||||
const baseTokens = committed.trim() ? committed.trim().split(/\s+/) : []
|
||||
const hypothesisTokens = cleanHypothesis.split(/\s+/)
|
||||
let index = 0
|
||||
while (
|
||||
index < baseTokens.length &&
|
||||
index < hypothesisTokens.length &&
|
||||
baseTokens[index] === hypothesisTokens[index]
|
||||
) {
|
||||
index += 1
|
||||
}
|
||||
if (index < baseTokens.length) return ""
|
||||
return hypothesisTokens.slice(index).join(" ")
|
||||
}
|
||||
|
||||
export function createSpeechRecognition(opts?: {
|
||||
lang?: string
|
||||
onFinal?: (text: string) => void
|
||||
onInterim?: (text: string) => void
|
||||
}) {
|
||||
const hasSupport =
|
||||
typeof window !== "undefined" &&
|
||||
Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
|
||||
|
||||
const [isRecording, setIsRecording] = createSignal(false)
|
||||
const [committed, setCommitted] = createSignal("")
|
||||
const [interim, setInterim] = createSignal("")
|
||||
|
||||
let recognition: Recognition | undefined
|
||||
let shouldContinue = false
|
||||
let committedText = ""
|
||||
let sessionCommitted = ""
|
||||
let pendingHypothesis = ""
|
||||
let lastInterimSuffix = ""
|
||||
let shrinkCandidate: string | undefined
|
||||
let commitTimer: number | undefined
|
||||
|
||||
const cancelPendingCommit = () => {
|
||||
if (commitTimer === undefined) return
|
||||
clearTimeout(commitTimer)
|
||||
commitTimer = undefined
|
||||
}
|
||||
|
||||
const commitSegment = (segment: string) => {
|
||||
const nextCommitted = appendSegment(committedText, segment)
|
||||
if (nextCommitted === committedText) return
|
||||
committedText = nextCommitted
|
||||
setCommitted(committedText)
|
||||
if (opts?.onFinal) opts.onFinal(segment.trim())
|
||||
}
|
||||
|
||||
const promotePending = () => {
|
||||
if (!pendingHypothesis) return
|
||||
const suffix = extractSuffix(sessionCommitted, pendingHypothesis)
|
||||
if (!suffix) {
|
||||
pendingHypothesis = ""
|
||||
return
|
||||
}
|
||||
sessionCommitted = appendSegment(sessionCommitted, suffix)
|
||||
commitSegment(suffix)
|
||||
pendingHypothesis = ""
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
}
|
||||
|
||||
const applyInterim = (suffix: string, hypothesis: string) => {
|
||||
cancelPendingCommit()
|
||||
pendingHypothesis = hypothesis
|
||||
lastInterimSuffix = suffix
|
||||
shrinkCandidate = undefined
|
||||
setInterim(suffix)
|
||||
if (opts?.onInterim) {
|
||||
opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
|
||||
}
|
||||
if (!suffix) return
|
||||
const snapshot = hypothesis
|
||||
commitTimer = window.setTimeout(() => {
|
||||
if (pendingHypothesis !== snapshot) return
|
||||
const currentSuffix = extractSuffix(sessionCommitted, pendingHypothesis)
|
||||
if (!currentSuffix) return
|
||||
sessionCommitted = appendSegment(sessionCommitted, currentSuffix)
|
||||
commitSegment(currentSuffix)
|
||||
pendingHypothesis = ""
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
}, COMMIT_DELAY)
|
||||
}
|
||||
|
||||
if (hasSupport) {
|
||||
const Ctor: new () => Recognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition
|
||||
|
||||
recognition = new Ctor()
|
||||
recognition.continuous = false
|
||||
recognition.interimResults = true
|
||||
recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US")
|
||||
|
||||
recognition.onresult = (event: RecognitionEvent) => {
|
||||
if (!event.results.length) return
|
||||
|
||||
let aggregatedFinal = ""
|
||||
let latestHypothesis = ""
|
||||
|
||||
for (let i = 0; i < event.results.length; i += 1) {
|
||||
const result = event.results[i]
|
||||
const transcript = (result[0]?.transcript || "").trim()
|
||||
if (!transcript) continue
|
||||
if (result.isFinal) {
|
||||
aggregatedFinal = appendSegment(aggregatedFinal, transcript)
|
||||
} else {
|
||||
latestHypothesis = transcript
|
||||
}
|
||||
}
|
||||
|
||||
if (aggregatedFinal) {
|
||||
cancelPendingCommit()
|
||||
const finalSuffix = extractSuffix(sessionCommitted, aggregatedFinal)
|
||||
if (finalSuffix) {
|
||||
sessionCommitted = appendSegment(sessionCommitted, finalSuffix)
|
||||
commitSegment(finalSuffix)
|
||||
}
|
||||
pendingHypothesis = ""
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
return
|
||||
}
|
||||
|
||||
cancelPendingCommit()
|
||||
|
||||
if (!latestHypothesis) {
|
||||
shrinkCandidate = undefined
|
||||
applyInterim("", "")
|
||||
return
|
||||
}
|
||||
|
||||
const suffix = extractSuffix(sessionCommitted, latestHypothesis)
|
||||
|
||||
if (!suffix) {
|
||||
if (!lastInterimSuffix) {
|
||||
shrinkCandidate = undefined
|
||||
applyInterim("", latestHypothesis)
|
||||
return
|
||||
}
|
||||
if (shrinkCandidate === "") {
|
||||
applyInterim("", latestHypothesis)
|
||||
return
|
||||
}
|
||||
shrinkCandidate = ""
|
||||
pendingHypothesis = latestHypothesis
|
||||
return
|
||||
}
|
||||
|
||||
if (lastInterimSuffix && suffix.length < lastInterimSuffix.length) {
|
||||
if (shrinkCandidate === suffix) {
|
||||
applyInterim(suffix, latestHypothesis)
|
||||
return
|
||||
}
|
||||
shrinkCandidate = suffix
|
||||
pendingHypothesis = latestHypothesis
|
||||
return
|
||||
}
|
||||
|
||||
shrinkCandidate = undefined
|
||||
applyInterim(suffix, latestHypothesis)
|
||||
}
|
||||
|
||||
recognition.onerror = (e: { error: string }) => {
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
if (e.error === "no-speech" && shouldContinue) {
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
setTimeout(() => {
|
||||
try {
|
||||
recognition?.start()
|
||||
} catch {}
|
||||
}, 150)
|
||||
return
|
||||
}
|
||||
shouldContinue = false
|
||||
setIsRecording(false)
|
||||
}
|
||||
|
||||
recognition.onstart = () => {
|
||||
sessionCommitted = ""
|
||||
pendingHypothesis = ""
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
setIsRecording(true)
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setIsRecording(false)
|
||||
if (shouldContinue) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
recognition?.start()
|
||||
} catch {}
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (!recognition) return
|
||||
shouldContinue = true
|
||||
sessionCommitted = ""
|
||||
pendingHypothesis = ""
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
try {
|
||||
recognition.start()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (!recognition) return
|
||||
shouldContinue = false
|
||||
promotePending()
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
try {
|
||||
recognition.stop()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
shouldContinue = false
|
||||
promotePending()
|
||||
cancelPendingCommit()
|
||||
lastInterimSuffix = ""
|
||||
shrinkCandidate = undefined
|
||||
setInterim("")
|
||||
if (opts?.onInterim) opts.onInterim("")
|
||||
try {
|
||||
recognition?.stop()
|
||||
} catch {}
|
||||
})
|
||||
|
||||
return {
|
||||
isSupported: () => hasSupport,
|
||||
isRecording,
|
||||
committed,
|
||||
interim,
|
||||
start,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user