فهرست منبع

wip: desktop work

Adam 4 ماه پیش
والد
کامیت
bb82d43094

+ 2 - 111
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>
   )
 }

+ 164 - 0
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
+}

+ 396 - 0
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
+    },
+  }
+}

+ 357 - 71
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"

+ 5 - 4
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>

+ 24 - 0
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)
+      },
     }
   })()
 

+ 226 - 87
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>

+ 4 - 2
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) {