wip: desktop work

This commit is contained in:
Adam
2025-10-13 06:09:02 -05:00
parent 2893b6e3a5
commit bb82d43094
8 changed files with 1179 additions and 276 deletions

View File

@@ -13,30 +13,16 @@ import {
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { LocalFile } from "@/context/local" import type { LocalFile } from "@/context/local"
import { Code } from "@/components/code" import { Code } from "@/components/code"
import PromptForm from "@/components/prompt-form" import { useLocal } from "@/context"
import { useLocal, useSDK, useSync } from "@/context"
import { getFilename } from "@/utils"
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
interface EditorPaneProps { interface EditorPaneProps {
layoutKey: string
timelinePane: string
onFileClick: (file: LocalFile) => void onFileClick: (file: LocalFile) => void
onOpenModelSelect: () => void
onInputRefChange: (element: HTMLTextAreaElement | null) => void
} }
export default function EditorPane(props: EditorPaneProps): JSX.Element { export default function EditorPane(props: EditorPaneProps): JSX.Element {
const [localProps] = splitProps(props, [ const [localProps] = splitProps(props, ["onFileClick"])
"layoutKey",
"timelinePane",
"onFileClick",
"onOpenModelSelect",
"onInputRefChange",
])
const local = useLocal() const local = useLocal()
const sdk = useSDK()
const sync = useSync()
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined) const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
const navigateChange = (dir: 1 | -1) => { const navigateChange = (dir: 1 | -1) => {
@@ -55,73 +41,6 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
local.file.close(file.path) 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 handleDragStart = (event: unknown) => {
const id = getDraggableId(event) const id = getDraggableId(event)
if (!id) return if (!id) return
@@ -146,7 +65,6 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
return ( return (
<div class="relative flex h-full flex-col"> <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 <DragDropProvider
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
@@ -237,23 +155,6 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
) )
})()} })()}
</Show> </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>
</div> </div>
<For each={local.file.opened()}> <For each={local.file.opened()}>
@@ -283,16 +184,6 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
})()} })()}
</DragOverlay> </DragOverlay>
</DragDropProvider> </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> </div>
) )
} }

View File

@@ -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
}

View File

@@ -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
},
}
}

View File

@@ -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 { Button, FileIcon, Icon, IconButton, Tooltip } from "@/ui"
import { Select } from "@/components/select" import { Select } from "@/components/select"
import { useLocal } from "@/context" import { useLocal } from "@/context"
import type { FileContext, LocalFile } from "@/context/local" import type { FileContext, LocalFile } from "@/context/local"
import { getFilename } from "@/utils" import { getDirectory, getFilename } from "@/utils"
import { createSpeechRecognition } from "@/utils/speech" 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 { interface PromptFormProps {
class?: string class?: string
classList?: Record<string, boolean> classList?: Record<string, boolean>
onSubmit: (prompt: string) => Promise<void> | void onSubmit: (prompt: PromptSubmitValue) => Promise<void> | void
onOpenModelSelect: () => void onOpenModelSelect: () => void
onInputRefChange?: (element: HTMLTextAreaElement | undefined) => void onInputRefChange?: (element: HTMLTextAreaElement | undefined) => void
} }
@@ -17,8 +27,16 @@ interface PromptFormProps {
export default function PromptForm(props: PromptFormProps) { export default function PromptForm(props: PromptFormProps) {
const local = useLocal() const local = useLocal()
const [prompt, setPrompt] = createSignal("") const [state, setState] = createStore<PromptFormState>({
const [isDragOver, setIsDragOver] = createSignal(false) 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..." const placeholderText = "Start typing or speaking..."
@@ -28,79 +46,212 @@ export default function PromptForm(props: PromptFormProps) {
interim: interimTranscript, interim: interimTranscript,
start: startSpeech, start: startSpeech,
stop: stopSpeech, stop: stopSpeech,
} = createSpeechRecognition({ } = usePromptSpeech((updater) => setState("promptInput", updater))
onFinal: (text) => setPrompt((prev) => (prev && !prev.endsWith(" ") ? prev + " " : prev) + text),
})
let inputRef: HTMLTextAreaElement | undefined = undefined let inputRef: HTMLTextAreaElement | undefined = undefined
let overlayContainerRef: HTMLDivElement | undefined = undefined let overlayContainerRef: HTMLDivElement | undefined = undefined
let shouldAutoScroll = true let mentionMeasureRef: HTMLDivElement | undefined = undefined
const promptContent = createMemo(() => { const attachmentLookup = createMemo(() => {
const base = prompt() || "" 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],
)
}
return map
})
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 { 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() : "" const interim = isRecording() ? interimTranscript() : ""
if (!base && !interim) { return composeDisplaySegments(segments, value, interim)
return <span class="text-text-muted/70">{placeholderText}</span>
}
const needsSpace = base && interim && !base.endsWith(" ") && !interim.startsWith(" ")
return (
<>
<span class="text-text">{base}</span>
{interim && (
<span class="text-text-muted/60 italic">
{needsSpace ? " " : ""}
{interim}
</span>
)}
</>
)
}) })
createEffect(() => { const hasDisplaySegments = createMemo(() => displaySegments().length > 0)
prompt()
interimTranscript() function handleAttachmentNavigation(
queueMicrotask(() => { event: KeyboardEvent & { currentTarget: HTMLTextAreaElement },
if (!inputRef) return direction: "left" | "right",
if (!overlayContainerRef) return ) {
if (!shouldAutoScroll) { const element = event.currentTarget
overlayContainerRef.scrollTop = inputRef.scrollTop const caret = element.selectionStart ?? 0
return 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,
)
} }
scrollPromptToEnd() if (!match) return false
})
})
const handlePromptKeyDown = (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => {
if (event.isComposing) return
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault() 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 }) => { 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>
}
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 target = event.currentTarget
shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4 const key = event.key
if (overlayContainerRef) overlayContainerRef.scrollTop = target.scrollTop
}
const scrollPromptToEnd = () => { const handled = handleMentionKeyDown({
if (!inputRef) return event,
const maxInputScroll = inputRef.scrollHeight - inputRef.clientHeight mentionItems,
const next = maxInputScroll > 0 ? maxInputScroll : 0 insertMention,
inputRef.scrollTop = next })
if (overlayContainerRef) overlayContainerRef.scrollTop = next if (handled) return
shouldAutoScroll = true
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) => { const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault() event.preventDefault()
const currentPrompt = prompt() const parts = baseParts()
setPrompt("") const text = parts
shouldAutoScroll = true .map((part) => {
if (overlayContainerRef) overlayContainerRef.scrollTop = 0 if (part.kind === "text") return part.value
return `@${part.path}`
})
.join("")
const currentPrompt: PromptSubmitValue = {
text,
parts,
}
setState("promptInput", "")
resetScrollPosition()
if (inputRef) { if (inputRef) {
inputRef.scrollTop = 0
inputRef.blur() inputRef.blur()
} }
@@ -114,26 +265,25 @@ export default function PromptForm(props: PromptFormProps) {
return ( return (
<form onSubmit={handleSubmit} class={props.class} classList={props.classList}> <form onSubmit={handleSubmit} class={props.class} classList={props.classList}>
<div <div
class="w-full max-w-xl min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs class="w-full min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
flex flex-col gap-1 flex flex-col gap-1 bg-gradient-to-b from-background-panel/90 to-background/90
bg-gradient-to-b from-background-panel/90 to-background/90
ring-1 ring-border-active/50 border border-transparent ring-1 ring-border-active/50 border border-transparent
focus-within:ring-2 focus-within:ring-primary/40 focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/40 focus-within:border-primary
transition-all duration-200" transition-all duration-200"
classList={{ classList={{
"shadow-[0_0_33px_rgba(0,0,0,0.8)]": !!local.file.active(), "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) => { onDragEnter={(event) => {
const evt = event as unknown as globalThis.DragEvent const evt = event as unknown as globalThis.DragEvent
if (evt.dataTransfer?.types.includes("text/plain")) { if (evt.dataTransfer?.types.includes("text/plain")) {
evt.preventDefault() evt.preventDefault()
setIsDragOver(true) setState("isDragOver", true)
} }
}} }}
onDragLeave={(event) => { onDragLeave={(event) => {
if (event.currentTarget === event.target) { if (event.currentTarget === event.target) {
setIsDragOver(false) setState("isDragOver", false)
} }
}} }}
onDragOver={(event) => { onDragOver={(event) => {
@@ -146,7 +296,7 @@ export default function PromptForm(props: PromptFormProps) {
onDrop={(event) => { onDrop={(event) => {
const evt = event as unknown as globalThis.DragEvent const evt = event as unknown as globalThis.DragEvent
evt.preventDefault() evt.preventDefault()
setIsDragOver(false) setState("isDragOver", false)
const data = evt.dataTransfer?.getData("text/plain") const data = evt.dataTransfer?.getData("text/plain")
if (data && data.startsWith("file:")) { if (data && data.startsWith("file:")) {
@@ -177,9 +327,24 @@ export default function PromptForm(props: PromptFormProps) {
inputRef = element ?? undefined inputRef = element ?? undefined
props.onInputRefChange?.(inputRef) props.onInputRefChange?.(inputRef)
}} }}
value={prompt()} value={state.promptInput}
onInput={(event) => setPrompt(event.currentTarget.value)} onInput={handlePromptInput}
onKeyDown={handlePromptKeyDown} 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} onScroll={handlePromptScroll}
placeholder={placeholderText} placeholder={placeholderText}
autocapitalize="off" autocapitalize="off"
@@ -196,10 +361,30 @@ export default function PromptForm(props: PromptFormProps) {
}} }}
class="pointer-events-none absolute inset-0 overflow-hidden" 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"> <PromptDisplayOverlay
{promptContent()} hasDisplaySegments={hasDisplaySegments()}
</div> displaySegments={displaySegments()}
placeholder={placeholderText}
renderAttachmentChip={renderAttachmentChip}
renderTextSegment={renderTextSegment}
/>
</div> </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>
<div class="flex justify-between items-center text-xs text-text-muted"> <div class="flex justify-between items-center text-xs text-text-muted">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
@@ -293,3 +478,104 @@ const FileTag = (props: { file: FileContext; onClose: () => void }) => (
</div> </div>
</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"

View File

@@ -74,7 +74,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
[props.class ?? ""]: !!props.class, [props.class ?? ""]: !!props.class,
}} }}
> >
<KobalteSelect.Value<T>> <KobalteSelect.Value<T> class="truncate">
{(state) => { {(state) => {
const selected = state.selectedOption() ?? props.current const selected = state.selectedOption() ?? props.current
if (!selected) return props.placeholder || "" if (!selected) return props.placeholder || ""
@@ -84,10 +84,11 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
</KobalteSelect.Value> </KobalteSelect.Value>
<KobalteSelect.Icon <KobalteSelect.Icon
classList={{ 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.Icon>
</KobalteSelect.Trigger> </KobalteSelect.Trigger>
<KobalteSelect.Portal> <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, "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.Content>
</KobalteSelect.Portal> </KobalteSelect.Portal>
</KobalteSelect> </KobalteSelect>

View File

@@ -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 open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
const relativePath = relative(path) const relativePath = relative(path)
if (!store.node[relativePath]) await fetch(path) if (!store.node[relativePath]) await fetch(path)
@@ -271,6 +278,7 @@ function init() {
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)), update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
open, open,
load, load,
init,
close(path: string) { close(path: string) {
setStore("opened", (opened) => opened.filter((x) => x !== path)) setStore("opened", (opened) => opened.filter((x) => x !== path))
if (store.active === path) { if (store.active === path) {
@@ -473,11 +481,16 @@ function init() {
const context = (() => { const context = (() => {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
activeTab: boolean activeTab: boolean
files: string[]
activeFile?: string
items: (ContextItem & { key: string })[] items: (ContextItem & { key: string })[]
}>({ }>({
activeTab: true, activeTab: true,
files: [],
items: [], items: [],
}) })
const files = createMemo(() => store.files.map((x) => file.node(x)))
const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
return { return {
all() { all() {
@@ -505,6 +518,17 @@ function init() {
remove(key: string) { remove(key: string) {
setStore("items", (x) => x.filter((x) => x.key !== key)) 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)
},
} }
})() })()

View File

@@ -1,28 +1,30 @@
import { FileIcon, Icon, IconButton, Tooltip } from "@/ui" 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 FileTree from "@/components/file-tree"
import EditorPane from "@/components/editor-pane" import EditorPane from "@/components/editor-pane"
import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js" import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { SelectDialog } from "@/components/select-dialog" import { SelectDialog } from "@/components/select-dialog"
import { useLocal } from "@/context" import { useSync, useSDK, useLocal } from "@/context"
import { ResizeableLayout, ResizeablePane } from "@/components/resizeable-pane" import type { LocalFile, TextSelection } from "@/context/local"
import type { LocalFile } from "@/context/local"
import SessionList from "@/components/session-list" import SessionList from "@/components/session-list"
import SessionTimeline from "@/components/session-timeline" import SessionTimeline from "@/components/session-timeline"
import PromptForm, { type PromptContentPart, type PromptSubmitValue } from "@/components/prompt-form"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils" import { getDirectory, getFilename } from "@/utils"
import { Select } from "@/components/select"
import { Tabs } from "@/ui/tabs"
import { Code } from "@/components/code"
export default function Page() { export default function Page() {
const local = useLocal() const local = useLocal()
const sync = useSync()
const sdk = useSDK()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
clickTimer: undefined as number | undefined, clickTimer: undefined as number | undefined,
modelSelectOpen: false, modelSelectOpen: false,
fileSelectOpen: false, fileSelectOpen: false,
}) })
const layoutKey = "workspace"
const timelinePane = "timeline"
let inputRef: HTMLTextAreaElement | undefined = undefined let inputRef: HTMLTextAreaElement | undefined = undefined
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" 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 ( return (
<div class="relative"> <div class="relative">
<ResizeableLayout <div class="h-screen flex">
id={layoutKey} <div class="shrink-0 w-56">
defaults={{ <SessionList />
explorer: { size: 24, visible: true }, </div>
editor: { size: 56, visible: true }, <div class="grow w-full min-w-0 overflow-y-auto flex justify-center">
timeline: { size: 20, visible: false }, <Show when={local.session.active()}>
}} {(activeSession) => <SessionTimeline session={activeSession().id} class="max-w-xl" />}
class="h-screen" </Show>
> </div>
<ResizeablePane <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
id="explorer" <FileTree path="" onFileClick={handleFileClick} />
minSize="150px" </div>
maxSize="300px" <div class="hidden shrink-0 w-56 p-2">
class="border-r border-border-subtle/30 bg-background z-10 overflow-hidden" <Show
> when={local.file.changes().length}
<Tabs class="relative flex flex-col h-full" defaultValue="files"> fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
<div class="sticky top-0 shrink-0 flex"> >
<Tabs.List class="grow w-full after:hidden"> <ul class="">
<Tabs.Trigger value="files" class="flex-1 justify-center text-xs"> <For each={local.file.changes()}>
Files {(path) => (
</Tabs.Trigger> <li>
<Tabs.Trigger value="changes" class="flex-1 justify-center text-xs"> <button
Changes onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
</Tabs.Trigger> 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"
</Tabs.List> >
</div> <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
<Tabs.Content value="files" class="grow min-h-0 py-2 bg-background"> <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
<FileTree path="" onFileClick={handleFileClick} /> <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
</Tabs.Content> {getDirectory(path)}
<Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background"> </span>
<Show </button>
when={local.file.changes().length} </li>
fallback={<div class="px-2 text-xs text-text-muted">No changes</div>} )}
> </For>
<ul class=""> </ul>
<For each={local.file.changes()}> </Show>
{(path) => ( </div>
<li> <div class="hidden grow min-w-0">
<button <EditorPane onFileClick={handleFileClick} />
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })} </div>
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" <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">
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" /> <Select
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span> options={sync.data.session}
<span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0"> current={local.session.active()}
{getDirectory(path)} placeholder="New Session"
</span> value={(x) => x.id}
</button> label={(x) => x.title}
</li> 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> </For>
</ul> </Tabs.List>
</Show> </div>
</Tabs.Content> <For each={local.context.files()}>
</Tabs> {(file) => (
</ResizeablePane> <Tabs.Content value={file.path} class="grow h-full pt-1 select-text rounded-md">
<ResizeablePane id="editor" minSize={30} maxSize={80} class="bg-background"> <Code path={file.path} code={file.content?.content ?? ""} />
<EditorPane </Tabs.Content>
layoutKey={layoutKey} )}
timelinePane={timelinePane} </For>
onFileClick={handleFileClick} </Tabs>
</div>
<PromptForm
onSubmit={handlePromptSubmit}
onOpenModelSelect={() => setStore("modelSelectOpen", true)} onOpenModelSelect={() => setStore("modelSelectOpen", true)}
onInputRefChange={(element: HTMLTextAreaElement | null) => { onInputRefChange={(element: HTMLTextAreaElement | undefined) => {
inputRef = element ?? undefined inputRef = element ?? undefined
}} }}
/> />
</ResizeablePane> <div class="hidden relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<ResizeablePane <Show when={local.session.active()}>
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 />}>
{(activeSession) => ( {(activeSession) => (
<div class="relative"> <div class="relative">
<div class="sticky top-0 bg-background z-50 px-2 h-8 border-b border-border-subtle/30"> <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"> <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"> <h2 class="text-sm font-medium text-text truncate">
{activeSession().title || "Untitled Session"} {activeSession().title || "Untitled Session"}
</h2> </h2>
@@ -203,8 +341,8 @@ export default function Page() {
)} )}
</Show> </Show>
</div> </div>
</ResizeablePane> </div>
</ResizeableLayout> </div>
<Show when={store.modelSelectOpen}> <Show when={store.modelSelectOpen}>
<SelectDialog <SelectDialog
key={(x) => `${x.provider.id}:${x.id}`} key={(x) => `${x.provider.id}:${x.id}`}
@@ -270,7 +408,8 @@ export default function Page() {
</div> </div>
)} )}
onClose={() => setStore("fileSelectOpen", false)} 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> </Show>
</div> </div>

View File

@@ -1,6 +1,8 @@
export function getFilename(path: string) { export function getFilename(path: string) {
const parts = path.split("/") if (!path) return ""
return parts[parts.length - 1] const trimmed = path.replace(/[\/]+$/, "")
const parts = trimmed.split("/")
return parts[parts.length - 1] ?? ""
} }
export function getDirectory(path: string) { export function getDirectory(path: string) {