summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/desktop/src/components/editor-pane.tsx113
-rw-r--r--packages/desktop/src/components/prompt-form-helpers.ts164
-rw-r--r--packages/desktop/src/components/prompt-form-hooks.ts396
-rw-r--r--packages/desktop/src/components/prompt-form.tsx428
-rw-r--r--packages/desktop/src/components/select.tsx9
-rw-r--r--packages/desktop/src/context/local.tsx24
-rw-r--r--packages/desktop/src/pages/index.tsx313
-rw-r--r--packages/desktop/src/utils/path.ts6
8 files changed, 1178 insertions, 275 deletions
diff --git a/packages/desktop/src/components/editor-pane.tsx b/packages/desktop/src/components/editor-pane.tsx
index faf70811d..2f4c87e25 100644
--- a/packages/desktop/src/components/editor-pane.tsx
+++ b/packages/desktop/src/components/editor-pane.tsx
@@ -13,30 +13,16 @@ import {
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 { useLocal } from "@/context"
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 [localProps] = splitProps(props, ["onFileClick"])
const local = useLocal()
- const sdk = useSDK()
- const sync = useSync()
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
const navigateChange = (dir: 1 | -1) => {
@@ -55,73 +41,6 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
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
@@ -146,7 +65,6 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
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}
@@ -237,23 +155,6 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
)
})()}
</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()}>
@@ -283,16 +184,6 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
})()}
</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>
)
}
diff --git a/packages/desktop/src/components/prompt-form-helpers.ts b/packages/desktop/src/components/prompt-form-helpers.ts
new file mode 100644
index 000000000..cc657c80e
--- /dev/null
+++ b/packages/desktop/src/components/prompt-form-helpers.ts
@@ -0,0 +1,164 @@
+import type { TextSelection } from "@/context/local"
+import { getFilename } from "@/utils"
+
+export interface PromptTextPart {
+ kind: "text"
+ value: string
+}
+
+export interface PromptAttachmentPart {
+ kind: "attachment"
+ token: string
+ display: string
+ path: string
+ selection?: TextSelection
+ origin: "context" | "active"
+}
+
+export interface PromptInterimPart {
+ kind: "interim"
+ value: string
+ leadingSpace: boolean
+}
+
+export type PromptContentPart = PromptTextPart | PromptAttachmentPart
+
+export type PromptDisplaySegment =
+ | { kind: "text"; value: string }
+ | { kind: "attachment"; part: PromptAttachmentPart; source: string }
+ | PromptInterimPart
+
+export interface AttachmentCandidate {
+ origin: "context" | "active"
+ path: string
+ selection?: TextSelection
+ display: string
+}
+
+export interface PromptSubmitValue {
+ text: string
+ parts: PromptContentPart[]
+}
+
+export const mentionPattern = /@([A-Za-z0-9_\-./]+)/g
+export const mentionTriggerPattern = /(^|\s)@([A-Za-z0-9_\-./]*)$/
+
+export type PromptSegment = (PromptTextPart | PromptAttachmentPart) & {
+ start: number
+ end: number
+}
+
+export type PromptAttachmentSegment = PromptAttachmentPart & {
+ start: number
+ end: number
+}
+
+function pushTextPart(parts: PromptContentPart[], value: string) {
+ if (!value) return
+ const last = parts[parts.length - 1]
+ if (last && last.kind === "text") {
+ last.value += value
+ return
+ }
+ parts.push({ kind: "text", value })
+}
+
+function addTextSegment(segments: PromptSegment[], start: number, value: string) {
+ if (!value) return
+ segments.push({ kind: "text", value, start, end: start + value.length })
+}
+
+export function createAttachmentDisplay(path: string, selection?: TextSelection) {
+ const base = getFilename(path)
+ if (!selection) return base
+ return `${base} (${selection.startLine}-${selection.endLine})`
+}
+
+export function registerCandidate(
+ map: Map<string, AttachmentCandidate>,
+ candidate: AttachmentCandidate,
+ tokens: (string | undefined)[],
+) {
+ for (const token of tokens) {
+ if (!token) continue
+ const normalized = token.toLowerCase()
+ if (map.has(normalized)) continue
+ map.set(normalized, candidate)
+ }
+}
+
+export function parsePrompt(value: string, lookup: Map<string, AttachmentCandidate>) {
+ const segments: PromptSegment[] = []
+ if (!value) return { parts: [] as PromptContentPart[], segments }
+
+ const pushTextRange = (rangeStart: number, rangeEnd: number) => {
+ if (rangeEnd <= rangeStart) return
+ const text = value.slice(rangeStart, rangeEnd)
+ let cursor = 0
+ for (const match of text.matchAll(mentionPattern)) {
+ const localIndex = match.index ?? 0
+ if (localIndex > cursor) {
+ addTextSegment(segments, rangeStart + cursor, text.slice(cursor, localIndex))
+ }
+ const token = match[1]
+ const candidate = lookup.get(token.toLowerCase())
+ if (candidate) {
+ const start = rangeStart + localIndex
+ const end = start + match[0].length
+ segments.push({
+ kind: "attachment",
+ token,
+ display: candidate.display,
+ path: candidate.path,
+ selection: candidate.selection,
+ origin: candidate.origin,
+ start,
+ end,
+ })
+ } else {
+ addTextSegment(segments, rangeStart + localIndex, match[0])
+ }
+ cursor = localIndex + match[0].length
+ }
+ if (cursor < text.length) {
+ addTextSegment(segments, rangeStart + cursor, text.slice(cursor))
+ }
+ }
+
+ pushTextRange(0, value.length)
+
+ const parts: PromptContentPart[] = []
+ for (const segment of segments) {
+ if (segment.kind === "text") {
+ pushTextPart(parts, segment.value)
+ } else {
+ const { start, end, ...attachment } = segment
+ parts.push(attachment as PromptAttachmentPart)
+ }
+ }
+ return { parts, segments }
+}
+
+export function composeDisplaySegments(
+ segments: PromptSegment[],
+ inputValue: string,
+ interim: string,
+): PromptDisplaySegment[] {
+ if (segments.length === 0 && !interim) return []
+
+ const display: PromptDisplaySegment[] = segments.map((segment) => {
+ if (segment.kind === "text") {
+ return { kind: "text", value: segment.value }
+ }
+ const { start, end, ...part } = segment
+ const placeholder = inputValue.slice(start, end)
+ return { kind: "attachment", part: part as PromptAttachmentPart, source: placeholder }
+ })
+
+ if (interim) {
+ const leadingSpace = !!(inputValue && !inputValue.endsWith(" ") && !interim.startsWith(" "))
+ display.push({ kind: "interim", value: interim, leadingSpace })
+ }
+
+ return display
+}
diff --git a/packages/desktop/src/components/prompt-form-hooks.ts b/packages/desktop/src/components/prompt-form-hooks.ts
new file mode 100644
index 000000000..026b84359
--- /dev/null
+++ b/packages/desktop/src/components/prompt-form-hooks.ts
@@ -0,0 +1,396 @@
+import { createEffect, createMemo, createResource, type Accessor } from "solid-js"
+import type { SetStoreFunction } from "solid-js/store"
+import { getDirectory, getFilename } from "@/utils"
+import { createSpeechRecognition } from "@/utils/speech"
+import {
+ createAttachmentDisplay,
+ mentionPattern,
+ mentionTriggerPattern,
+ type PromptAttachmentPart,
+ type PromptAttachmentSegment,
+} from "./prompt-form-helpers"
+import type { LocalFile, TextSelection } from "@/context/local"
+
+export type MentionRange = {
+ start: number
+ end: number
+}
+
+export interface PromptFormState {
+ promptInput: string
+ isDragOver: boolean
+ mentionOpen: boolean
+ mentionQuery: string
+ mentionRange: MentionRange | undefined
+ mentionIndex: number
+ mentionAnchorOffset: { x: number; y: number }
+ inlineAliases: Map<string, PromptAttachmentPart>
+}
+
+interface MentionControllerOptions {
+ state: PromptFormState
+ setState: SetStoreFunction<PromptFormState>
+ attachmentSegments: Accessor<PromptAttachmentSegment[]>
+ getInputRef: () => HTMLTextAreaElement | undefined
+ getOverlayRef: () => HTMLDivElement | undefined
+ getMeasureRef: () => HTMLDivElement | undefined
+ searchFiles: (query: string) => Promise<string[]>
+ resolveFile: (path: string) => LocalFile | undefined
+ addContextFile: (path: string, selection?: TextSelection) => void
+ getActiveContext: () => { path: string; selection?: TextSelection } | undefined
+}
+
+interface MentionKeyDownOptions {
+ event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }
+ mentionItems: () => string[]
+ insertMention: (path: string) => void
+}
+
+interface ScrollSyncOptions {
+ state: PromptFormState
+ getInputRef: () => HTMLTextAreaElement | undefined
+ getOverlayRef: () => HTMLDivElement | undefined
+ interim: Accessor<string>
+ updateMentionPosition: (element: HTMLTextAreaElement, range?: MentionRange) => void
+}
+
+export function usePromptSpeech(updatePromptInput: (updater: (prev: string) => string) => void) {
+ return createSpeechRecognition({
+ onFinal: (text) => updatePromptInput((prev) => (prev && !prev.endsWith(" ") ? `${prev} ` : prev) + text),
+ })
+}
+
+export function useMentionController(options: MentionControllerOptions) {
+ const mentionSource = createMemo(() => (options.state.mentionOpen ? options.state.mentionQuery : undefined))
+ const [mentionResults, { mutate: mutateMentionResults }] = createResource(mentionSource, (query) => {
+ if (!options.state.mentionOpen) return []
+ return options.searchFiles(query ?? "")
+ })
+ const mentionItems = createMemo(() => mentionResults() ?? [])
+
+ createEffect(() => {
+ if (!options.state.mentionOpen) return
+ options.state.mentionQuery
+ options.setState("mentionIndex", 0)
+ })
+
+ createEffect(() => {
+ if (!options.state.mentionOpen) return
+ queueMicrotask(() => {
+ const input = options.getInputRef()
+ if (!input) return
+ if (document.activeElement === input) return
+ input.focus()
+ })
+ })
+
+ createEffect(() => {
+ const used = new Set<string>()
+ for (const match of options.state.promptInput.matchAll(mentionPattern)) {
+ const token = match[1]
+ if (token) used.add(token.toLowerCase())
+ }
+ options.setState("inlineAliases", (prev) => {
+ if (prev.size === 0) return prev
+ const next = new Map(prev)
+ let changed = false
+ for (const key of prev.keys()) {
+ if (!used.has(key.toLowerCase())) {
+ next.delete(key)
+ changed = true
+ }
+ }
+ return changed ? next : prev
+ })
+ })
+
+ createEffect(() => {
+ if (!options.state.mentionOpen) return
+ const items = mentionItems()
+ if (items.length === 0) {
+ options.setState("mentionIndex", 0)
+ return
+ }
+ if (options.state.mentionIndex < items.length) return
+ options.setState("mentionIndex", items.length - 1)
+ })
+
+ createEffect(() => {
+ if (!options.state.mentionOpen) return
+ const rangeValue = options.state.mentionRange
+ if (!rangeValue) return
+ options.state.promptInput
+ queueMicrotask(() => {
+ const input = options.getInputRef()
+ if (!input) return
+ updateMentionPosition(input, rangeValue)
+ })
+ })
+
+ function closeMention() {
+ if (options.state.mentionOpen) options.setState("mentionOpen", false)
+ options.setState("mentionQuery", "")
+ options.setState("mentionRange", undefined)
+ options.setState("mentionIndex", 0)
+ mutateMentionResults(() => undefined)
+ options.setState("mentionAnchorOffset", { x: 0, y: 0 })
+ }
+
+ function updateMentionPosition(element: HTMLTextAreaElement, rangeValue = options.state.mentionRange) {
+ const measure = options.getMeasureRef()
+ if (!measure) return
+ if (!rangeValue) return
+ measure.style.width = `${element.clientWidth}px`
+ const measurement = element.value.slice(0, rangeValue.end)
+ measure.textContent = measurement
+ const caretSpan = document.createElement("span")
+ caretSpan.textContent = "\u200b"
+ measure.append(caretSpan)
+ const caretRect = caretSpan.getBoundingClientRect()
+ const containerRect = measure.getBoundingClientRect()
+ measure.removeChild(caretSpan)
+ const left = caretRect.left - containerRect.left
+ const top = caretRect.top - containerRect.top - element.scrollTop
+ options.setState("mentionAnchorOffset", { x: left, y: top < 0 ? 0 : top })
+ }
+
+ function isValidMentionQuery(value: string) {
+ return /^[A-Za-z0-9_\-./]*$/.test(value)
+ }
+
+ function syncMentionFromCaret(element: HTMLTextAreaElement) {
+ if (!options.state.mentionOpen) return
+ const rangeValue = options.state.mentionRange
+ if (!rangeValue) {
+ closeMention()
+ return
+ }
+ const caret = element.selectionEnd ?? element.selectionStart ?? element.value.length
+ if (rangeValue.start < 0 || rangeValue.start >= element.value.length) {
+ closeMention()
+ return
+ }
+ if (element.value[rangeValue.start] !== "@") {
+ closeMention()
+ return
+ }
+ if (caret <= rangeValue.start) {
+ closeMention()
+ return
+ }
+ const mentionValue = element.value.slice(rangeValue.start + 1, caret)
+ if (!isValidMentionQuery(mentionValue)) {
+ closeMention()
+ return
+ }
+ options.setState("mentionRange", { start: rangeValue.start, end: caret })
+ options.setState("mentionQuery", mentionValue)
+ updateMentionPosition(element, { start: rangeValue.start, end: caret })
+ }
+
+ function tryOpenMentionFromCaret(element: HTMLTextAreaElement) {
+ const selectionStart = element.selectionStart ?? element.value.length
+ const selectionEnd = element.selectionEnd ?? selectionStart
+ if (selectionStart !== selectionEnd) return false
+ const caret = selectionEnd
+ if (options.attachmentSegments().some((segment) => caret >= segment.start && caret <= segment.end)) {
+ return false
+ }
+ const before = element.value.slice(0, caret)
+ const match = before.match(mentionTriggerPattern)
+ if (!match) return false
+ const token = match[2] ?? ""
+ const start = caret - token.length - 1
+ if (start < 0) return false
+ options.setState("mentionOpen", true)
+ options.setState("mentionRange", { start, end: caret })
+ options.setState("mentionQuery", token)
+ options.setState("mentionIndex", 0)
+ queueMicrotask(() => {
+ updateMentionPosition(element, { start, end: caret })
+ })
+ return true
+ }
+
+ function handlePromptInput(event: InputEvent & { currentTarget: HTMLTextAreaElement }) {
+ const element = event.currentTarget
+ options.setState("promptInput", element.value)
+ if (options.state.mentionOpen) {
+ syncMentionFromCaret(element)
+ if (options.state.mentionOpen) return
+ }
+ const isDeletion = event.inputType ? event.inputType.startsWith("delete") : false
+ if (!isDeletion && tryOpenMentionFromCaret(element)) return
+ closeMention()
+ }
+
+ function handleMentionKeyDown({ event, mentionItems: items, insertMention }: MentionKeyDownOptions) {
+ if (!options.state.mentionOpen) return false
+ const list = items()
+ if (event.key === "ArrowDown") {
+ event.preventDefault()
+ if (list.length === 0) return true
+ const next = options.state.mentionIndex + 1 >= list.length ? 0 : options.state.mentionIndex + 1
+ options.setState("mentionIndex", next)
+ return true
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault()
+ if (list.length === 0) return true
+ const previous = options.state.mentionIndex - 1 < 0 ? list.length - 1 : options.state.mentionIndex - 1
+ options.setState("mentionIndex", previous)
+ return true
+ }
+ if (event.key === "Enter") {
+ event.preventDefault()
+ const targetItem = list[options.state.mentionIndex] ?? list[0]
+ if (targetItem) insertMention(targetItem)
+ return true
+ }
+ if (event.key === "Escape") {
+ event.preventDefault()
+ closeMention()
+ return true
+ }
+ return false
+ }
+
+ function generateMentionAlias(path: string) {
+ const existing = new Set<string>()
+ for (const key of options.state.inlineAliases.keys()) {
+ existing.add(key.toLowerCase())
+ }
+ for (const match of options.state.promptInput.matchAll(mentionPattern)) {
+ const token = match[1]
+ if (token) existing.add(token.toLowerCase())
+ }
+
+ const base = getFilename(path)
+ if (base) {
+ if (!existing.has(base.toLowerCase())) return base
+ }
+
+ const directory = getDirectory(path)
+ if (base && directory) {
+ const segments = directory.split("/").filter(Boolean)
+ for (let i = segments.length - 1; i >= 0; i -= 1) {
+ const candidate = `${segments.slice(i).join("/")}/${base}`
+ if (!existing.has(candidate.toLowerCase())) return candidate
+ }
+ }
+
+ if (!existing.has(path.toLowerCase())) return path
+
+ const fallback = base || path || "file"
+ let index = 2
+ let candidate = `${fallback}-${index}`
+ while (existing.has(candidate.toLowerCase())) {
+ index += 1
+ candidate = `${fallback}-${index}`
+ }
+ return candidate
+ }
+
+ function insertMention(path: string) {
+ const input = options.getInputRef()
+ if (!input) return
+ const rangeValue = options.state.mentionRange
+ if (!rangeValue) return
+ const node = options.resolveFile(path)
+ const alias = generateMentionAlias(path)
+ const mentionText = `@${alias}`
+ const value = options.state.promptInput
+ const before = value.slice(0, rangeValue.start)
+ const after = value.slice(rangeValue.end)
+ const needsLeadingSpace = before.length > 0 && !/\s$/.test(before)
+ const needsTrailingSpace = after.length > 0 && !/^\s/.test(after)
+ const leading = needsLeadingSpace ? `${before} ` : before
+ const trailingSpacer = needsTrailingSpace ? " " : ""
+ const nextValue = `${leading}${mentionText}${trailingSpacer}${after}`
+ const origin = options.getActiveContext()?.path === path ? "active" : "context"
+ const part: PromptAttachmentPart = {
+ kind: "attachment",
+ token: alias,
+ display: createAttachmentDisplay(path, node?.selection),
+ path,
+ selection: node?.selection,
+ origin,
+ }
+ options.setState("promptInput", nextValue)
+ if (input.value !== nextValue) {
+ input.value = nextValue
+ }
+ options.setState("inlineAliases", (prev) => {
+ const next = new Map(prev)
+ next.set(alias, part)
+ return next
+ })
+ options.addContextFile(path, node?.selection)
+ closeMention()
+ queueMicrotask(() => {
+ const caret = leading.length + mentionText.length + trailingSpacer.length
+ input.setSelectionRange(caret, caret)
+ syncMentionFromCaret(input)
+ })
+ }
+
+ return {
+ mentionResults,
+ mentionItems,
+ closeMention,
+ syncMentionFromCaret,
+ tryOpenMentionFromCaret,
+ updateMentionPosition,
+ handlePromptInput,
+ handleMentionKeyDown,
+ insertMention,
+ }
+}
+
+export function usePromptScrollSync(options: ScrollSyncOptions) {
+ let shouldAutoScroll = true
+
+ createEffect(() => {
+ options.state.promptInput
+ options.interim()
+ queueMicrotask(() => {
+ const input = options.getInputRef()
+ const overlay = options.getOverlayRef()
+ if (!input || !overlay) return
+ if (!shouldAutoScroll) {
+ overlay.scrollTop = input.scrollTop
+ if (options.state.mentionOpen) options.updateMentionPosition(input)
+ return
+ }
+ const maxInputScroll = input.scrollHeight - input.clientHeight
+ const next = maxInputScroll > 0 ? maxInputScroll : 0
+ input.scrollTop = next
+ overlay.scrollTop = next
+ if (options.state.mentionOpen) options.updateMentionPosition(input)
+ })
+ })
+
+ function handlePromptScroll(event: Event & { currentTarget: HTMLTextAreaElement }) {
+ const target = event.currentTarget
+ shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4
+ const overlay = options.getOverlayRef()
+ if (overlay) overlay.scrollTop = target.scrollTop
+ if (options.state.mentionOpen) options.updateMentionPosition(target)
+ }
+
+ function resetScrollPosition() {
+ shouldAutoScroll = true
+ const input = options.getInputRef()
+ const overlay = options.getOverlayRef()
+ if (input) input.scrollTop = 0
+ if (overlay) overlay.scrollTop = 0
+ }
+
+ return {
+ handlePromptScroll,
+ resetScrollPosition,
+ setAutoScroll: (value: boolean) => {
+ shouldAutoScroll = value
+ },
+ }
+}
diff --git a/packages/desktop/src/components/prompt-form.tsx b/packages/desktop/src/components/prompt-form.tsx
index 9d7c45a32..5e6441596 100644
--- a/packages/desktop/src/components/prompt-form.tsx
+++ b/packages/desktop/src/components/prompt-form.tsx
@@ -1,15 +1,25 @@
-import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
+import { For, Show, createMemo, onCleanup, type JSX } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Popover } from "@kobalte/core/popover"
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"
+import { getDirectory, getFilename } from "@/utils"
+import { composeDisplaySegments, createAttachmentDisplay, parsePrompt, registerCandidate } from "./prompt-form-helpers"
+import type {
+ AttachmentCandidate,
+ PromptAttachmentPart,
+ PromptAttachmentSegment,
+ PromptDisplaySegment,
+ PromptSubmitValue,
+} from "./prompt-form-helpers"
+import { useMentionController, usePromptScrollSync, usePromptSpeech, type PromptFormState } from "./prompt-form-hooks"
interface PromptFormProps {
class?: string
classList?: Record<string, boolean>
- onSubmit: (prompt: string) => Promise<void> | void
+ onSubmit: (prompt: PromptSubmitValue) => Promise<void> | void
onOpenModelSelect: () => void
onInputRefChange?: (element: HTMLTextAreaElement | undefined) => void
}
@@ -17,8 +27,16 @@ interface PromptFormProps {
export default function PromptForm(props: PromptFormProps) {
const local = useLocal()
- const [prompt, setPrompt] = createSignal("")
- const [isDragOver, setIsDragOver] = createSignal(false)
+ const [state, setState] = createStore<PromptFormState>({
+ promptInput: "",
+ isDragOver: false,
+ mentionOpen: false,
+ mentionQuery: "",
+ mentionRange: undefined,
+ mentionIndex: 0,
+ mentionAnchorOffset: { x: 0, y: 0 },
+ inlineAliases: new Map<string, PromptAttachmentPart>(),
+ })
const placeholderText = "Start typing or speaking..."
@@ -28,79 +46,212 @@ export default function PromptForm(props: PromptFormProps) {
interim: interimTranscript,
start: startSpeech,
stop: stopSpeech,
- } = createSpeechRecognition({
- onFinal: (text) => setPrompt((prev) => (prev && !prev.endsWith(" ") ? prev + " " : prev) + text),
- })
+ } = usePromptSpeech((updater) => setState("promptInput", updater))
let inputRef: HTMLTextAreaElement | undefined = undefined
let overlayContainerRef: HTMLDivElement | undefined = undefined
- let shouldAutoScroll = true
+ let mentionMeasureRef: HTMLDivElement | undefined = undefined
- const promptContent = createMemo(() => {
- const base = prompt() || ""
- const interim = isRecording() ? interimTranscript() : ""
- if (!base && !interim) {
- return <span class="text-text-muted/70">{placeholderText}</span>
+ const attachmentLookup = createMemo(() => {
+ const map = new Map<string, AttachmentCandidate>()
+ const activeFile = local.context.active()
+ if (activeFile) {
+ registerCandidate(
+ map,
+ {
+ origin: "active",
+ path: activeFile.path,
+ selection: activeFile.selection,
+ display: createAttachmentDisplay(activeFile.path, activeFile.selection),
+ },
+ [activeFile.path, getFilename(activeFile.path)],
+ )
+ }
+ for (const item of local.context.all()) {
+ registerCandidate(
+ map,
+ {
+ origin: "context",
+ path: item.path,
+ selection: item.selection,
+ display: createAttachmentDisplay(item.path, item.selection),
+ },
+ [item.path, getFilename(item.path)],
+ )
+ }
+ for (const [alias, part] of state.inlineAliases) {
+ registerCandidate(
+ map,
+ {
+ origin: part.origin,
+ path: part.path,
+ selection: part.selection,
+ display: part.display ?? createAttachmentDisplay(part.path, part.selection),
+ },
+ [alias],
+ )
}
- 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>
- )}
- </>
- )
+ return map
})
- createEffect(() => {
- prompt()
- interimTranscript()
- queueMicrotask(() => {
- if (!inputRef) return
- if (!overlayContainerRef) return
- if (!shouldAutoScroll) {
- overlayContainerRef.scrollTop = inputRef.scrollTop
- return
- }
- scrollPromptToEnd()
- })
+ const parsedPrompt = createMemo(() => parsePrompt(state.promptInput, attachmentLookup()))
+ const baseParts = createMemo(() => parsedPrompt().parts)
+ const attachmentSegments = createMemo<PromptAttachmentSegment[]>(() =>
+ parsedPrompt().segments.filter((segment): segment is PromptAttachmentSegment => segment.kind === "attachment"),
+ )
+
+ const {
+ mentionResults,
+ mentionItems,
+ closeMention,
+ syncMentionFromCaret,
+ updateMentionPosition,
+ handlePromptInput,
+ handleMentionKeyDown,
+ insertMention,
+ } = useMentionController({
+ state,
+ setState,
+ attachmentSegments,
+ getInputRef: () => inputRef,
+ getOverlayRef: () => overlayContainerRef,
+ getMeasureRef: () => mentionMeasureRef,
+ searchFiles: (query) => local.file.search(query),
+ resolveFile: (path) => local.file.node(path) ?? undefined,
+ addContextFile: (path, selection) =>
+ local.context.add({
+ type: "file",
+ path,
+ selection,
+ }),
+ getActiveContext: () => local.context.active() ?? undefined,
})
- const handlePromptKeyDown = (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => {
- if (event.isComposing) return
- if (event.key === "Enter" && !event.shiftKey) {
+ const { handlePromptScroll, resetScrollPosition } = usePromptScrollSync({
+ state,
+ getInputRef: () => inputRef,
+ getOverlayRef: () => overlayContainerRef,
+ interim: () => (isRecording() ? interimTranscript() : ""),
+ updateMentionPosition,
+ })
+
+ const displaySegments = createMemo<PromptDisplaySegment[]>(() => {
+ const value = state.promptInput
+ const segments = parsedPrompt().segments
+ const interim = isRecording() ? interimTranscript() : ""
+ return composeDisplaySegments(segments, value, interim)
+ })
+
+ const hasDisplaySegments = createMemo(() => displaySegments().length > 0)
+
+ function handleAttachmentNavigation(
+ event: KeyboardEvent & { currentTarget: HTMLTextAreaElement },
+ direction: "left" | "right",
+ ) {
+ const element = event.currentTarget
+ const caret = element.selectionStart ?? 0
+ const segments = attachmentSegments()
+ if (direction === "left") {
+ let match = segments.find((segment) => caret > segment.start && caret <= segment.end)
+ if (!match && element.selectionStart !== element.selectionEnd) {
+ match = segments.find(
+ (segment) => element.selectionStart === segment.start && element.selectionEnd === segment.end,
+ )
+ }
+ if (!match) return false
event.preventDefault()
- inputRef?.form?.requestSubmit()
+ if (element.selectionStart === match.start && element.selectionEnd === match.end) {
+ const next = Math.max(0, match.start)
+ element.setSelectionRange(next, next)
+ syncMentionFromCaret(element)
+ return true
+ }
+ element.setSelectionRange(match.start, match.end)
+ syncMentionFromCaret(element)
+ return true
}
+ if (direction === "right") {
+ let match = segments.find((segment) => caret >= segment.start && caret < segment.end)
+ if (!match && element.selectionStart !== element.selectionEnd) {
+ match = segments.find(
+ (segment) => element.selectionStart === segment.start && element.selectionEnd === segment.end,
+ )
+ }
+ if (!match) return false
+ event.preventDefault()
+ if (element.selectionStart === match.start && element.selectionEnd === match.end) {
+ const next = match.end
+ element.setSelectionRange(next, next)
+ syncMentionFromCaret(element)
+ return true
+ }
+ element.setSelectionRange(match.start, match.end)
+ syncMentionFromCaret(element)
+ return true
+ }
+ return false
}
- const handlePromptScroll = (event: Event & { currentTarget: HTMLTextAreaElement }) => {
- const target = event.currentTarget
- shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4
- if (overlayContainerRef) overlayContainerRef.scrollTop = target.scrollTop
+ function renderAttachmentChip(part: PromptAttachmentPart, _placeholder: string) {
+ const display = part.display ?? createAttachmentDisplay(part.path, part.selection)
+ return <span class="truncate max-w-[16ch] text-primary">@{display}</span>
}
- 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
+ function renderTextSegment(value: string) {
+ if (!value) return undefined
+ return <span class="text-text">{value}</span>
+ }
+
+ function handlePromptKeyDown(event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) {
+ if (event.isComposing) return
+ const target = event.currentTarget
+ const key = event.key
+
+ const handled = handleMentionKeyDown({
+ event,
+ mentionItems,
+ insertMention,
+ })
+ if (handled) return
+
+ if (!state.mentionOpen) {
+ if (key === "ArrowLeft") {
+ if (handleAttachmentNavigation(event, "left")) return
+ }
+ if (key === "ArrowRight") {
+ if (handleAttachmentNavigation(event, "right")) return
+ }
+ }
+
+ if (key === "ArrowLeft" || key === "ArrowRight" || key === "Home" || key === "End") {
+ queueMicrotask(() => {
+ syncMentionFromCaret(target)
+ })
+ }
+
+ if (key === "Enter" && !event.shiftKey) {
+ event.preventDefault()
+ target.form?.requestSubmit()
+ }
}
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault()
- const currentPrompt = prompt()
- setPrompt("")
- shouldAutoScroll = true
- if (overlayContainerRef) overlayContainerRef.scrollTop = 0
+ const parts = baseParts()
+ const text = parts
+ .map((part) => {
+ if (part.kind === "text") return part.value
+ return `@${part.path}`
+ })
+ .join("")
+
+ const currentPrompt: PromptSubmitValue = {
+ text,
+ parts,
+ }
+ setState("promptInput", "")
+ resetScrollPosition()
if (inputRef) {
- inputRef.scrollTop = 0
inputRef.blur()
}
@@ -114,26 +265,25 @@ export default function PromptForm(props: PromptFormProps) {
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
+ class="w-full 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(),
+ "ring-2 ring-primary/60 bg-primary/5": state.isDragOver,
}}
onDragEnter={(event) => {
const evt = event as unknown as globalThis.DragEvent
if (evt.dataTransfer?.types.includes("text/plain")) {
evt.preventDefault()
- setIsDragOver(true)
+ setState("isDragOver", true)
}
}}
onDragLeave={(event) => {
if (event.currentTarget === event.target) {
- setIsDragOver(false)
+ setState("isDragOver", false)
}
}}
onDragOver={(event) => {
@@ -146,7 +296,7 @@ export default function PromptForm(props: PromptFormProps) {
onDrop={(event) => {
const evt = event as unknown as globalThis.DragEvent
evt.preventDefault()
- setIsDragOver(false)
+ setState("isDragOver", false)
const data = evt.dataTransfer?.getData("text/plain")
if (data && data.startsWith("file:")) {
@@ -177,9 +327,24 @@ export default function PromptForm(props: PromptFormProps) {
inputRef = element ?? undefined
props.onInputRefChange?.(inputRef)
}}
- value={prompt()}
- onInput={(event) => setPrompt(event.currentTarget.value)}
+ value={state.promptInput}
+ onInput={handlePromptInput}
onKeyDown={handlePromptKeyDown}
+ onClick={(event) =>
+ queueMicrotask(() => {
+ syncMentionFromCaret(event.currentTarget)
+ })
+ }
+ onSelect={(event) =>
+ queueMicrotask(() => {
+ syncMentionFromCaret(event.currentTarget)
+ })
+ }
+ onBlur={(event) => {
+ const next = event.relatedTarget as HTMLElement | null
+ if (next && next.closest('[data-mention-popover="true"]')) return
+ closeMention()
+ }}
onScroll={handlePromptScroll}
placeholder={placeholderText}
autocapitalize="off"
@@ -196,10 +361,30 @@ export default function PromptForm(props: PromptFormProps) {
}}
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>
+ <PromptDisplayOverlay
+ hasDisplaySegments={hasDisplaySegments()}
+ displaySegments={displaySegments()}
+ placeholder={placeholderText}
+ renderAttachmentChip={renderAttachmentChip}
+ renderTextSegment={renderTextSegment}
+ />
</div>
+ <div
+ ref={(element) => {
+ mentionMeasureRef = element ?? undefined
+ }}
+ class="pointer-events-none invisible absolute inset-0 whitespace-pre-wrap text-base font-light leading-relaxed px-0.5"
+ aria-hidden="true"
+ ></div>
+ <MentionSuggestions
+ open={state.mentionOpen}
+ anchor={state.mentionAnchorOffset}
+ loading={mentionResults.loading}
+ items={mentionItems()}
+ activeIndex={state.mentionIndex}
+ onHover={(index) => setState("mentionIndex", index)}
+ onSelect={insertMention}
+ />
</div>
<div class="flex justify-between items-center text-xs text-text-muted">
<div class="flex gap-2 items-center">
@@ -293,3 +478,104 @@ const FileTag = (props: { file: FileContext; onClose: () => void }) => (
</div>
</div>
)
+
+function PromptDisplayOverlay(props: {
+ hasDisplaySegments: boolean
+ displaySegments: PromptDisplaySegment[]
+ placeholder: string
+ renderAttachmentChip: (part: PromptAttachmentPart, placeholder: string) => JSX.Element
+ renderTextSegment: (value: string) => JSX.Element | undefined
+}) {
+ return (
+ <div class="px-0.5 text-base font-light leading-relaxed whitespace-pre-wrap text-left">
+ <Show when={props.hasDisplaySegments} fallback={<span class="text-text-muted/70">{props.placeholder}</span>}>
+ <For each={props.displaySegments}>
+ {(segment) => {
+ if (segment.kind === "text") {
+ return props.renderTextSegment(segment.value)
+ }
+ if (segment.kind === "attachment") {
+ return props.renderAttachmentChip(segment.part, segment.source)
+ }
+ return (
+ <span class="text-text-muted/60 italic">
+ {segment.leadingSpace ? ` ${segment.value}` : segment.value}
+ </span>
+ )
+ }}
+ </For>
+ </Show>
+ </div>
+ )
+}
+
+function MentionSuggestions(props: {
+ open: boolean
+ anchor: { x: number; y: number }
+ loading: boolean
+ items: string[]
+ activeIndex: number
+ onHover: (index: number) => void
+ onSelect: (path: string) => void
+}) {
+ return (
+ <Popover open={props.open} modal={false} gutter={8} placement="bottom-start">
+ <Popover.Trigger class="hidden" />
+ <Popover.Anchor
+ class="pointer-events-none absolute top-0 left-0 w-0 h-0"
+ style={{ transform: `translate(${props.anchor.x}px, ${props.anchor.y}px)` }}
+ />
+ <Popover.Portal>
+ <Popover.Content
+ data-mention-popover="true"
+ class="z-50 w-72 max-h-60 overflow-y-auto rounded-md border border-border-subtle/40 bg-background-panel shadow-[0_10px_30px_rgba(0,0,0,0.35)] focus:outline-none"
+ >
+ <div class="py-1">
+ <Show when={props.loading}>
+ <div class="flex items-center gap-2 px-3 py-2 text-xs text-text-muted">
+ <Icon name="refresh" size={12} class="animate-spin" />
+ <span>Searching…</span>
+ </div>
+ </Show>
+ <Show when={!props.loading && props.items.length === 0}>
+ <div class="px-3 py-2 text-xs text-text-muted/80">No matching files</div>
+ </Show>
+ <For each={props.items}>
+ {(path, indexAccessor) => {
+ const index = indexAccessor()
+ const dir = getDirectory(path)
+ return (
+ <button
+ type="button"
+ onMouseDown={(event) => event.preventDefault()}
+ onMouseEnter={() => props.onHover(index)}
+ onClick={() => props.onSelect(path)}
+ class="w-full px-3 py-2 flex items-center gap-2 rounded-md text-left text-xs transition-colors"
+ classList={{
+ "bg-background-element text-text": index === props.activeIndex,
+ "text-text-muted": index !== props.activeIndex,
+ }}
+ >
+ <FileIcon node={{ path, type: "file" }} class="size-3 shrink-0" />
+ <div class="flex flex-col min-w-0">
+ <span class="truncate">{getFilename(path)}</span>
+ {dir && <span class="truncate text-text-muted/70">{dir}</span>}
+ </div>
+ </button>
+ )
+ }}
+ </For>
+ </div>
+ </Popover.Content>
+ </Popover.Portal>
+ </Popover>
+ )
+}
+
+export type {
+ PromptAttachmentPart,
+ PromptAttachmentSegment,
+ PromptContentPart,
+ PromptDisplaySegment,
+ PromptSubmitValue,
+} from "./prompt-form-helpers"
diff --git a/packages/desktop/src/components/select.tsx b/packages/desktop/src/components/select.tsx
index 3df8c9999..6ab3f4012 100644
--- a/packages/desktop/src/components/select.tsx
+++ b/packages/desktop/src/components/select.tsx
@@ -74,7 +74,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
[props.class ?? ""]: !!props.class,
}}
>
- <KobalteSelect.Value<T>>
+ <KobalteSelect.Value<T> class="truncate">
{(state) => {
const selected = state.selectedOption() ?? props.current
if (!selected) return props.placeholder || ""
@@ -84,10 +84,11 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
</KobalteSelect.Value>
<KobalteSelect.Icon
classList={{
- "size-fit shrink-0 text-text-muted transition-transform duration-100 data-[expanded]:rotate-180": true,
+ "group size-fit shrink-0 text-text-muted transition-transform duration-100": true,
}}
>
- <Icon name="chevron-down" size={24} />
+ <Icon name="chevron-up" size={16} class="-my-2 group-data-[expanded]:rotate-180" />
+ <Icon name="chevron-down" size={16} class="-my-2 group-data-[expanded]:rotate-180" />
</KobalteSelect.Icon>
</KobalteSelect.Trigger>
<KobalteSelect.Portal>
@@ -99,7 +100,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
"data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true,
}}
>
- <KobalteSelect.Listbox class="overflow-y-auto max-h-48" />
+ <KobalteSelect.Listbox class="overflow-y-auto max-h-48 whitespace-nowrap overflow-x-hidden" />
</KobalteSelect.Content>
</KobalteSelect.Portal>
</KobalteSelect>
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index 4c2a3d3be..b04c70f0f 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/packages/desktop/src/context/local.tsx
@@ -202,6 +202,13 @@ function init() {
}
}
+ const init = async (path: string) => {
+ const relativePath = relative(path)
+ if (!store.node[relativePath]) await fetch(path)
+ if (store.node[relativePath].loaded) return
+ return load(relativePath)
+ }
+
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
const relativePath = relative(path)
if (!store.node[relativePath]) await fetch(path)
@@ -271,6 +278,7 @@ function init() {
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
open,
load,
+ init,
close(path: string) {
setStore("opened", (opened) => opened.filter((x) => x !== path))
if (store.active === path) {
@@ -473,11 +481,16 @@ function init() {
const context = (() => {
const [store, setStore] = createStore<{
activeTab: boolean
+ files: string[]
+ activeFile?: string
items: (ContextItem & { key: string })[]
}>({
activeTab: true,
+ files: [],
items: [],
})
+ const files = createMemo(() => store.files.map((x) => file.node(x)))
+ const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
return {
all() {
@@ -505,6 +518,17 @@ function init() {
remove(key: string) {
setStore("items", (x) => x.filter((x) => x.key !== key))
},
+ files,
+ openFile(path: string) {
+ file.init(path).then(() => {
+ setStore("files", (x) => [...x, path])
+ setStore("activeFile", path)
+ })
+ },
+ activeFile,
+ setActiveFile(path: string | undefined) {
+ setStore("activeFile", path)
+ },
}
})()
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
index 128c6adfd..f344830a4 100644
--- a/packages/desktop/src/pages/index.tsx
+++ b/packages/desktop/src/pages/index.tsx
@@ -1,28 +1,30 @@
import { FileIcon, Icon, IconButton, Tooltip } from "@/ui"
-import { Tabs } from "@/ui/tabs"
+import * as KobalteTabs from "@kobalte/core/tabs"
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 } from "@/context"
-import { ResizeableLayout, ResizeablePane } from "@/components/resizeable-pane"
-import type { LocalFile } from "@/context/local"
+import { useSync, useSDK, useLocal } from "@/context"
+import type { LocalFile, TextSelection } from "@/context/local"
import SessionList from "@/components/session-list"
import SessionTimeline from "@/components/session-timeline"
+import PromptForm, { type PromptContentPart, type PromptSubmitValue } from "@/components/prompt-form"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
+import { Select } from "@/components/select"
+import { Tabs } from "@/ui/tabs"
+import { Code } from "@/components/code"
export default function Page() {
const local = useLocal()
+ const sync = useSync()
+ const sdk = useSDK()
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
modelSelectOpen: false,
fileSelectOpen: false,
})
- 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"
@@ -104,95 +106,231 @@ export default function Page() {
}
}
+ const handlePromptSubmit = async (prompt: PromptSubmitValue) => {
+ const existingSession = local.session.active()
+ let session = existingSession
+ if (!session) {
+ const created = await sdk.session.create()
+ session = created.data ?? undefined
+ }
+ if (!session) return
+ local.session.setActive(session.id)
+
+ interface SubmissionAttachment {
+ path: string
+ selection?: TextSelection
+ label: string
+ }
+
+ const createAttachmentKey = (path: string, selection?: TextSelection) => {
+ if (!selection) return path
+ return `${path}:${selection.startLine}:${selection.startChar}:${selection.endLine}:${selection.endChar}`
+ }
+
+ const formatAttachmentLabel = (path: string, selection?: TextSelection) => {
+ if (!selection) return getFilename(path)
+ return `${getFilename(path)} (${selection.startLine}-${selection.endLine})`
+ }
+
+ const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
+
+ const attachments = new Map<string, SubmissionAttachment>()
+
+ const registerAttachment = (path: string, selection: TextSelection | undefined, label?: string) => {
+ if (!path) return
+ const key = createAttachmentKey(path, selection)
+ if (attachments.has(key)) return
+ attachments.set(key, {
+ path,
+ selection,
+ label: label ?? formatAttachmentLabel(path, selection),
+ })
+ }
+
+ const promptAttachments = prompt.parts.filter(
+ (part): part is Extract<PromptContentPart, { kind: "attachment" }> => part.kind === "attachment",
+ )
+
+ for (const part of promptAttachments) {
+ registerAttachment(part.path, part.selection, part.display)
+ }
+
+ const activeFile = local.context.active()
+ if (activeFile) {
+ registerAttachment(
+ activeFile.path,
+ activeFile.selection,
+ activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
+ )
+ }
+
+ for (const contextFile of local.context.all()) {
+ registerAttachment(
+ contextFile.path,
+ contextFile.selection,
+ formatAttachmentLabel(contextFile.path, contextFile.selection),
+ )
+ }
+
+ const attachmentParts = Array.from(attachments.values()).map((attachment) => {
+ const absolute = toAbsolutePath(attachment.path)
+ const query = attachment.selection
+ ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
+ : ""
+ return {
+ type: "file" as const,
+ mime: "text/plain",
+ url: `file://${absolute}${query}`,
+ filename: getFilename(attachment.path),
+ source: {
+ type: "file" as const,
+ text: {
+ value: `@${attachment.label}`,
+ start: 0,
+ end: 0,
+ },
+ path: absolute,
+ },
+ }
+ })
+
+ 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.text,
+ },
+ ...attachmentParts,
+ ],
+ },
+ })
+ }
+
return (
<div class="relative">
- <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">
- <Tabs.List class="grow w-full after:hidden">
- <Tabs.Trigger value="files" class="flex-1 justify-center text-xs">
- Files
- </Tabs.Trigger>
- <Tabs.Trigger value="changes" class="flex-1 justify-center text-xs">
- Changes
- </Tabs.Trigger>
- </Tabs.List>
- </div>
- <Tabs.Content value="files" class="grow min-h-0 py-2 bg-background">
- <FileTree path="" onFileClick={handleFileClick} />
- </Tabs.Content>
- <Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
- <Show
- when={local.file.changes().length}
- fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
- >
- <ul class="">
- <For each={local.file.changes()}>
- {(path) => (
- <li>
- <button
- onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
- class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 cursor-pointer hover:bg-background-element"
- >
- <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
- <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
- <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
- {getDirectory(path)}
- </span>
- </button>
- </li>
+ <div class="h-screen flex">
+ <div class="shrink-0 w-56">
+ <SessionList />
+ </div>
+ <div class="grow w-full min-w-0 overflow-y-auto flex justify-center">
+ <Show when={local.session.active()}>
+ {(activeSession) => <SessionTimeline session={activeSession().id} class="max-w-xl" />}
+ </Show>
+ </div>
+ <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
+ <FileTree path="" onFileClick={handleFileClick} />
+ </div>
+ <div class="hidden shrink-0 w-56 p-2">
+ <Show
+ when={local.file.changes().length}
+ fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
+ >
+ <ul class="">
+ <For each={local.file.changes()}>
+ {(path) => (
+ <li>
+ <button
+ onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
+ class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 cursor-pointer hover:bg-background-element"
+ >
+ <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
+ <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
+ <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
+ {getDirectory(path)}
+ </span>
+ </button>
+ </li>
+ )}
+ </For>
+ </ul>
+ </Show>
+ </div>
+ <div class="hidden grow min-w-0">
+ <EditorPane onFileClick={handleFileClick} />
+ </div>
+ <div class="absolute bottom-4 right-4 border border-border-subtle/60 p-2 rounded-xl bg-background w-xl flex flex-col gap-2 z-50">
+ <div class="flex items-center gap-2">
+ <Select
+ options={sync.data.session}
+ current={local.session.active()}
+ placeholder="New Session"
+ value={(x) => x.id}
+ label={(x) => x.title}
+ onSelect={(s) => local.session.setActive(s?.id)}
+ class="bg-transparent! max-w-48 pl-0! text-text-muted!"
+ />
+ <Show when={local.session.active()}>
+ <>
+ <div>/</div>
+ <Select
+ options={sync.data.message[local.session.active()!.id]?.filter((m) => m.role === "user") ?? []}
+ label={(m) => sync.data.part[m.id].find((p) => p.type === "text")!.text}
+ class="bg-transparent! max-w-48 pl-0! text-text-muted!"
+ />
+ </>
+ </Show>
+ </div>
+ <div class="h-72 text-xs overflow-x-scroll no-scrollbar w-full min-w-0">
+ <Tabs
+ class="relative grow w-full flex flex-col gap-1 h-full"
+ value={local.context.activeFile()?.path}
+ onChange={local.context.setActiveFile}
+ >
+ <div class="sticky top-0 shrink-0 flex items-center gap-1">
+ <IconButton
+ class="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="secondary"
+ onClick={() => setStore("fileSelectOpen", true)}
+ >
+ <Icon name="plus" size={12} />
+ </IconButton>
+ <Tabs.List class="grow after:hidden! h-full divide-none! gap-1">
+ <For each={local.context.files()}>
+ {(file) => (
+ <KobalteTabs.Trigger
+ value={file.path}
+ class="h-full"
+ // onClick={() => props.onTabClick(props.file)}
+ >
+ <div class="flex items-center gap-x-1 rounded-md bg-background-panel px-2 h-full">
+ <FileIcon node={file} class="shrink-0 size-3!" />
+ <span class="text-xs text-text whitespace-nowrap">{getFilename(file.path)}</span>
+ </div>
+ </KobalteTabs.Trigger>
)}
</For>
- </ul>
- </Show>
- </Tabs.Content>
- </Tabs>
- </ResizeablePane>
- <ResizeablePane id="editor" minSize={30} maxSize={80} class="bg-background">
- <EditorPane
- layoutKey={layoutKey}
- timelinePane={timelinePane}
- onFileClick={handleFileClick}
+ </Tabs.List>
+ </div>
+ <For each={local.context.files()}>
+ {(file) => (
+ <Tabs.Content value={file.path} class="grow h-full pt-1 select-text rounded-md">
+ <Code path={file.path} code={file.content?.content ?? ""} />
+ </Tabs.Content>
+ )}
+ </For>
+ </Tabs>
+ </div>
+ <PromptForm
+ onSubmit={handlePromptSubmit}
onOpenModelSelect={() => setStore("modelSelectOpen", true)}
- onInputRefChange={(element: HTMLTextAreaElement | null) => {
+ onInputRefChange={(element: HTMLTextAreaElement | undefined) => {
inputRef = element ?? undefined
}}
/>
- </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 />}>
+ <div class="hidden relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
+ <Show when={local.session.active()}>
{(activeSession) => (
<div class="relative">
<div class="sticky top-0 bg-background z-50 px-2 h-8 border-b border-border-subtle/30">
<div class="h-full flex items-center gap-2">
- <IconButton
- size="xs"
- variant="ghost"
- onClick={() => local.session.clearActive()}
- class="text-text-muted hover:text-text"
- >
- <Icon name="arrow-left" size={14} />
- </IconButton>
<h2 class="text-sm font-medium text-text truncate">
{activeSession().title || "Untitled Session"}
</h2>
@@ -203,8 +341,8 @@ export default function Page() {
)}
</Show>
</div>
- </ResizeablePane>
- </ResizeableLayout>
+ </div>
+ </div>
<Show when={store.modelSelectOpen}>
<SelectDialog
key={(x) => `${x.provider.id}:${x.id}`}
@@ -270,7 +408,8 @@ export default function Page() {
</div>
)}
onClose={() => setStore("fileSelectOpen", false)}
- onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
+ onSelect={(x) => (x ? local.context.openFile(x) : undefined)}
+ // onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
/>
</Show>
</div>
diff --git a/packages/desktop/src/utils/path.ts b/packages/desktop/src/utils/path.ts
index 9c026ca41..3ae48cdb3 100644
--- a/packages/desktop/src/utils/path.ts
+++ b/packages/desktop/src/utils/path.ts
@@ -1,6 +1,8 @@
export function getFilename(path: string) {
- const parts = path.split("/")
- return parts[parts.length - 1]
+ if (!path) return ""
+ const trimmed = path.replace(/[\/]+$/, "")
+ const parts = trimmed.split("/")
+ return parts[parts.length - 1] ?? ""
}
export function getDirectory(path: string) {