feat(agent-manager): drag sessions, worktrees, terminals, and documents into the prompt

This commit is contained in:
marius-kilocode
2026-09-14 09:10:03 +02:00
parent c36e226348
commit 81ecad2b13
19 changed files with 550 additions and 26 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Mention sessions, worktrees, terminals, and open documents by dragging their tab or card into the prompt.
@@ -0,0 +1,100 @@
import { afterEach, describe, expect, it } from "bun:test"
import {
beginPromptMentionDrop,
endPromptMentionDrop,
insideRect,
registerPromptMentionDrop,
type PromptMentionDrop,
} from "../../webview-ui/src/utils/prompt-mention-drop"
const hadDoc = "document" in globalThis
const originalDoc = hadDoc ? globalThis.document : undefined
const listeners = new Set<(event: PointerEvent) => void>()
function mockDocument() {
listeners.clear()
;(globalThis as Record<string, unknown>).document = {
addEventListener: (type: string, handler: (event: PointerEvent) => void) => {
if (type === "pointermove") listeners.add(handler)
},
removeEventListener: (type: string, handler: (event: PointerEvent) => void) => {
if (type === "pointermove") listeners.delete(handler)
},
}
}
function restoreDocument() {
if (hadDoc) (globalThis as Record<string, unknown>).document = originalDoc
else delete (globalThis as Record<string, unknown>).document
}
function move(x: number, y: number) {
for (const handler of listeners) handler({ clientX: x, clientY: y } as PointerEvent)
}
function target() {
return {
isConnected: true,
getBoundingClientRect: () => ({ left: 100, top: 100, right: 300, bottom: 200 }),
} as unknown as HTMLElement
}
const drop: PromptMentionDrop = {
kind: "session",
session: { id: "s1", title: "Chat", updated: 1 },
}
afterEach(() => {
registerPromptMentionDrop(undefined, undefined)
endPromptMentionDrop()
restoreDocument()
})
describe("insideRect", () => {
it("includes the edges and excludes outside points", () => {
const rect = { left: 10, top: 20, right: 30, bottom: 40 }
expect(insideRect(rect, 10, 20)).toBe(true)
expect(insideRect(rect, 30, 40)).toBe(true)
expect(insideRect(rect, 9, 20)).toBe(false)
expect(insideRect(rect, 30, 41)).toBe(false)
})
})
describe("prompt mention drop", () => {
it("inserts only when the last pointer position is inside the target", () => {
mockDocument()
const inserted: PromptMentionDrop[] = []
registerPromptMentionDrop(target(), (value) => {
inserted.push(value)
return true
})
beginPromptMentionDrop(drop)
move(200, 150)
expect(endPromptMentionDrop()).toBe(true)
expect(inserted).toEqual([drop])
})
it("does not insert when the pointer is outside the target", () => {
mockDocument()
const inserted: PromptMentionDrop[] = []
registerPromptMentionDrop(target(), (value) => {
inserted.push(value)
return true
})
beginPromptMentionDrop(drop)
move(500, 500)
expect(endPromptMentionDrop()).toBe(false)
expect(inserted).toEqual([])
})
it("does nothing when no target is registered", () => {
mockDocument()
beginPromptMentionDrop(drop)
expect(listeners.size).toBe(0)
expect(endPromptMentionDrop()).toBe(false)
})
})
@@ -1,7 +1,12 @@
import { describe, expect, it } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { useFileMention } from "../../webview-ui/src/hooks/useFileMention"
import { FILE_PICKER_RESULT, MODEL_RESULT, TERMINAL_RESULT } from "../../webview-ui/src/hooks/file-mention-utils"
import {
FILE_PICKER_RESULT,
MODEL_RESULT,
TERMINAL_RESULT,
type WorktreeReference,
} from "../../webview-ui/src/hooks/file-mention-utils"
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
declare global {
@@ -1925,3 +1930,88 @@ describe("useFileMention", () => {
dispose.fn?.()
})
})
describe("useFileMention reference drops", () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}
const worktree: WorktreeReference = {
id: "w1",
name: "Feature",
branch: "feature",
path: "/repo/worktrees/feature",
base: "main",
sessions: [{ id: "s1", title: "Chat" }],
disabled: false,
}
const withMention = (
text: string,
worktrees: WorktreeReference[] | undefined,
run: (mention: ReturnType<typeof useFileMention>, area: ReturnType<typeof editor>) => void,
) => {
const area = editor(text)
mockDocument(area)
const dispose: { fn?: () => void } = {}
let mention!: ReturnType<typeof useFileMention>
createRoot((root) => {
dispose.fn = root
mention = useFileMention(
ctx,
() => "s1",
() => false,
worktrees ? () => worktrees : undefined,
)
})
try {
run(mention, area)
} finally {
dispose.fn?.()
restoreDocument()
}
}
it("inserts a worktree reference after existing text and attaches it", () => {
withMention("hello", [worktree], (mention, area) => {
mention.insertDrop({ kind: "worktree", worktree }, area, () => {}, "")
expect(area.value).toBe("hello @/repo/worktrees/feature ")
expect(mention.mentionedPaths().has(worktree.path)).toBe(true)
expect(mention.parseFileAttachments(area.value).map((file) => file.filename)).toContain("worktree-w1.txt")
})
})
it("inserts a session reference and attaches it", () => {
withMention("", undefined, (mention, area) => {
mention.insertDrop({ kind: "session", session: { id: "s2", title: "My Chat", updated: 5 } }, area, () => {}, "")
expect(area.value).toBe("@My Chat ")
expect(mention.mentionedSessions().has("My Chat")).toBe(true)
expect(mention.parseFileAttachments(area.value).map((file) => file.url)).toContain("session:s2")
})
})
it("inserts the terminal reference", () => {
withMention("", undefined, (mention, area) => {
expect(mention.insertDrop({ kind: "terminal" }, area, () => {}, "")).toBe(true)
expect(area.value).toBe("@terminal ")
})
})
it("inserts a relative file reference from a document tab", () => {
withMention("", undefined, (mention, area) => {
expect(mention.insertDrop({ kind: "file", path: "/repo/docs/plan.md" }, area, () => {}, "/repo")).toBe(true)
expect(area.value).toBe("@docs/plan.md ")
expect(mention.mentionedPaths().has("docs/plan.md")).toBe(true)
})
})
it("skips a disabled worktree reference", () => {
withMention("", [worktree], (mention, area) => {
expect(
mention.insertDrop({ kind: "worktree", worktree: { ...worktree, disabled: true } }, area, () => {}, ""),
).toBe(false)
expect(area.value).toBe("")
})
})
})
@@ -7,7 +7,8 @@ import {
type DragEvent,
} from "@thisbeyond/solid-dnd"
import { For, Show, createSignal, type Accessor, type Component, type JSX } from "solid-js"
import { ConstrainDragYAxis } from "../src/components/chat/TabDnd"
import { ConstrainDragYAxis, outsideSidePanel } from "../src/components/chat/TabDnd"
import { beginPromptMentionDrop, endPromptMentionDrop, type PromptMentionDrop } from "../src/utils/prompt-mention-drop"
import { createTabFocus } from "../src/utils/tab-navigation"
import { useTabScroll } from "../src/utils/tab-scroll"
import { setTabWidths } from "../src/utils/tab-widths"
@@ -30,6 +31,8 @@ interface Props {
overlay: (id: string) => string
onSelect: (id: string) => void
onReorder: (from: string, to: string) => void
/** Prompt mention payload for a tab id, so the tab can be dragged to the prompt. */
drag?: (id: string) => PromptMentionDrop | undefined
action?: (api: InspectorTabStripApi) => JSX.Element
}
@@ -47,12 +50,18 @@ export const InspectorTabStrip: Component<Props> = (props) => {
const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width
freeze()
setDragging({ id, width })
const payload = props.drag?.(id)
if (payload) beginPromptMentionDrop(payload)
}
const end = () => {
endPromptMentionDrop()
setDragging(undefined)
release()
}
const over = (event: DragEvent) => {
// Once the tab leaves the side panel it is on its way to the prompt, so stop
// reordering the tabs under it. Only applies to drag-to-prompt strips.
if (props.drag && outsideSidePanel(event)) return
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from !== "string" || typeof to !== "string") return
@@ -37,13 +37,15 @@ import {
isGrouped,
} from "./section-helpers"
import { LOCAL, nextSelectionAfterDelete } from "./navigate"
import { sectionAwareDetector } from "./section-dnd"
import { outsideSidebar, sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { createProjectStore, type ProjectStore } from "./project/store"
import { randomColor } from "./section-colors"
import { projectSidebarOrder, projectWorktreeRow } from "./project-local-navigation"
import { rootSessions } from "./project/session-filter"
import { createWorktreeCompletion } from "./worktree-completion"
import { worktreeDropReference } from "./worktree-references"
import { beginPromptMentionDrop, endPromptMentionDrop } from "../src/utils/prompt-mention-drop"
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
@@ -201,10 +203,24 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
if (!id || !worktreeIds().has(id)) return
setDragging(id)
setDragOrigin(order())
const wt = worktrees().find((item) => item.id === id)
if (wt) {
beginPromptMentionDrop({
kind: "worktree",
worktree: worktreeDropReference(
wt,
wt.label || firstOrderedTitle(sessions(wt.id), store.tabOrder()[wt.id], wt.branch),
sessions(wt.id).map((session) => ({ id: session.id })),
),
})
}
document.body.classList.add("am-wt-dragging-active")
}
const onDragOver = (event: DragEvent) => {
// Once the card leaves the sidebar it is on its way to the prompt, so stop
// reordering the list under it.
if (outsideSidebar(event.draggable)) return
const from = parse("worktree", event.draggable?.id)
const to = parse("worktree", event.droppable?.id)
if (!from || !to || !worktreeIds().has(from) || !worktreeIds().has(to)) return
@@ -218,6 +234,7 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
}
const onDragEnd = (event: DragEvent) => {
const handled = endPromptMentionDrop()
const from = parse("worktree", event.draggable?.id)
const section = parse("section", event.droppable?.id)
const to = parse("worktree", event.droppable?.id)
@@ -225,6 +242,14 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
const origin = dragOrigin()
setDragOrigin(undefined)
document.body.classList.remove("am-wt-dragging-active")
// A drop on the prompt inserts a mention. Do not also move the worktree to
// whatever section happens to be under the pointer.
if (handled) return
// A release outside the sidebar is not a section move or list reorder.
if (outsideSidebar(event.draggable)) {
if (origin) store.setWorktreeOrder(origin)
return
}
if (!from || !worktreeIds().has(from)) {
if (origin) store.setWorktreeOrder(origin)
return
@@ -23,7 +23,9 @@ import { LOCAL, adjacentHint } from "./navigate"
import { applyTabOrder, reorderTabs } from "./tab-order"
import { buildTopLevelItems, isGroupEnd, isGroupStart, isGrouped } from "./section-helpers"
import { createWorktreeCompletion } from "./worktree-completion"
import { sectionAwareDetector } from "./section-dnd"
import { worktreeDropReference } from "./worktree-references"
import { beginPromptMentionDrop, endPromptMentionDrop } from "../src/utils/prompt-mention-drop"
import { outsideSidebar, sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { useVSCode } from "../src/context/vscode"
import SectionHeader from "./SectionHeader"
@@ -250,10 +252,29 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
const onWtDragStart = (event: DragEvent) => {
const id = event.draggable?.id
if (typeof id === "string") props.setDraggingWorktree(id)
if (typeof id === "string") {
props.setDraggingWorktree(id)
const wt = sorted().find((item) => item.id === id)
if (wt) {
beginPromptMentionDrop({
kind: "worktree",
worktree: worktreeDropReference(
wt,
props.worktreeLabel(wt),
props
.managedSessions()
.filter((session) => session.worktreeId === wt.id)
.map((session) => ({ id: session.id })),
),
})
}
}
document.body.classList.add("am-wt-dragging-active")
}
const onWtDragOver = (event: DragEvent) => {
// Once the card leaves the sidebar it is on its way to the
// prompt, so stop reordering the list under it.
if (outsideSidebar(event.draggable)) return
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from !== "string" || typeof to !== "string") return
@@ -267,10 +288,16 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
})
}
const onWtDragEnd = (event: DragEvent) => {
const handled = endPromptMentionDrop()
const from = event.draggable?.id
const to = event.droppable?.id
props.setDraggingWorktree(undefined)
document.body.classList.remove("am-wt-dragging-active")
// A drop on the prompt inserts a mention. Do not also move the
// worktree to whatever section happens to be under the pointer.
if (handled) return
// A release outside the sidebar is not a list reorder.
if (outsideSidebar(event.draggable)) return
if (typeof from === "string" && typeof to === "string" && secIds().has(to)) {
props.moveToSection([from], to)
return
@@ -1,12 +1,16 @@
import { type Component, createRoot, onCleanup } from "solid-js"
import { useDragDropContext, type Transformer } from "@thisbeyond/solid-dnd"
/** Lock drag movement to the Y axis (vertical-only worktree dragging). */
/**
* Keep worktree drags from drifting left off-screen while allowing movement to
* the right, so a card can leave the sidebar and be dropped on the prompt.
* Vertical position still drives the sortable reorder animation.
*/
export const ConstrainDragXAxis: Component = () => {
const ctx = useDragDropContext()
if (!ctx) return null
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = ctx
const xform: Transformer = { id: "constrain-x-axis", order: 100, callback: (t) => ({ ...t, x: 0 }) }
const xform: Transformer = { id: "constrain-x-axis", order: 100, callback: (t) => ({ ...t, x: Math.max(0, t.x) }) }
const dispose = createRoot((d) => {
onDragStart(({ draggable }) => {
if (draggable) addTransformer("draggables", draggable.id as string, xform)
@@ -33,3 +33,16 @@ export function sectionAwareDetector(
return closestCenter(draggable, droppables, ctx)
}
}
/**
* True once a dragged worktree card has moved right past its own bounds, which
* means it left the sidebar. The sidebar sorts by vertical position, so drag
* over must stop reordering once the card is out. Otherwise the siblings keep
* animating while the user drags toward the prompt.
*/
export function outsideSidebar(draggable: {
layout: { right: number }
transformed: { center: { x: number } }
}): boolean {
return draggable.transformed.center.x > draggable.layout.right
}
@@ -3,10 +3,12 @@ import type { DragEvent } from "@thisbeyond/solid-dnd"
import { LOCAL } from "./navigate"
import { applyTabOrder, reorderTabs } from "./tab-order"
import { isTerminalTabId, type TerminalStateControls } from "./terminal/state"
import { beginPromptMentionDrop, endPromptMentionDrop, sessionDrop } from "../src/utils/prompt-mention-drop"
import { outsideTabBar } from "../src/components/chat/TabDnd"
export function createTabDrag(opts: {
selection: Accessor<string | null>
sessions: Accessor<{ id: string; title?: string }[]>
sessions: Accessor<{ id: string; title?: string; updatedAt?: string }[]>
review: { id: string; open: Accessor<boolean>; title: Accessor<string> }
order: Accessor<Record<string, string[]>>
setOrder: Setter<Record<string, string[]>>
@@ -43,9 +45,19 @@ export function createTabDrag(opts: {
overlay,
start(event: DragEvent) {
const id = event.draggable?.id
if (typeof id === "string") setDragging(id)
if (typeof id !== "string") return
setDragging(id)
if (isTerminalTabId(id)) {
beginPromptMentionDrop({ kind: "terminal" })
return
}
const session = opts.sessions().find((item) => item.id === id)
if (session) beginPromptMentionDrop(sessionDrop(session))
},
over(event: DragEvent) {
// Once the tab is below the bar it is on its way to the prompt, so stop
// reordering the tabs under it.
if (outsideTabBar(event)) return
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from !== "string" || typeof to !== "string") return
@@ -60,6 +72,7 @@ export function createTabDrag(opts: {
if (terminals.length > 0) opts.terms.reorder(opts.namespace(key), terminals)
},
end() {
endPromptMentionDrop()
setDragging(undefined)
const key = opts.selection()
if (key === null) return
@@ -79,6 +79,7 @@ export const SideTerminalPanel: Component<Props> = (props) => {
overlay={(id) => props.state.title(id) ?? t("agentManager.tab.terminal")}
onSelect={props.onSelect}
onReorder={(from, to) => props.state.reorderSideDrag(props.contextKey(), from, to)}
drag={() => ({ kind: "terminal" })}
renderTab={(id, api) => {
const term = sides().find((item) => item.id === id)
if (!term) return null
@@ -1,5 +1,5 @@
import { createEffect, createMemo, type Accessor } from "solid-js"
import type { SessionInfo } from "../src/types/messages"
import type { SessionInfo, WorktreeState } from "../src/types/messages"
import type { useVSCode } from "../src/context/vscode"
import type { WorktreeReference } from "../src/hooks/file-mention-utils"
import type { ProjectStore } from "./project/store"
@@ -51,6 +51,27 @@ export function worktreeReferences(
)
}
/**
* Build the reference carried by a dragged worktree card. The sidebar already
* has the worktree state and its sessions, so the drop does not depend on the
* active project's mention list.
*/
export function worktreeDropReference(
worktree: WorktreeState,
name: string,
sessions: { id: string; title?: string }[],
): WorktreeReference {
return {
id: worktree.id,
name,
branch: worktree.branch,
path: worktree.path,
base: worktree.parentBranch,
sessions,
disabled: false,
}
}
export function createWorktreeReferences(
vscode: Pick<ReturnType<typeof useVSCode>, "getState" | "setState">,
state: Accessor<ProjectStore>,
@@ -271,6 +271,10 @@ export const DocumentPanel: Component<DocumentPanelProps> = (props) => {
overlay={(id) => props.tabs().find((tab) => tab.id === id)?.file ?? ""}
onSelect={props.onSelect}
onReorder={props.onReorder}
drag={(id) => {
const tab = props.tabs().find((item) => item.id === id)
return tab ? { kind: "file", path: tab.file } : undefined
}}
renderTab={(id, api) => {
const tab = props.tabs().find((item) => item.id === id)!
return (
@@ -3,7 +3,7 @@
* Text input with send/abort buttons, ghost-text autocomplete, and @ file mention support
*/
import { createSignal, createEffect, on, For, Index, onCleanup, Show, untrack, type Component } from "solid-js"
import { createSignal, createEffect, on, onMount, For, Index, onCleanup, Show, untrack, type Component } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
@@ -43,6 +43,7 @@ import { useSpeechToTextModels } from "../../context/speech-to-text-models"
import { createSpeechShortcut } from "../speech-to-text/shortcut"
import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageAttachments"
import { convertToMentionPath, insertPathMentions } from "../../utils/path-mentions"
import { promptMentionOver, registerPromptMentionDrop } from "../../utils/prompt-mention-drop"
import { SessionMentionPicker } from "./SessionMentionPicker"
import { formatRelativeDate } from "../../utils/date"
import { WorktreeMentionPicker } from "./WorktreeMentionPicker"
@@ -277,6 +278,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let highlightRef: HTMLDivElement | undefined
let dropdownRef: HTMLDivElement | undefined
let slashDropdownRef: HTMLDivElement | undefined
let containerRef: HTMLDivElement | undefined
/**
* True after the last menu entry of a bare `@`, which lists the entries above
@@ -1637,10 +1639,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (textareaRef) textareaRef.style.height = "auto"
}
onMount(() => {
registerPromptMentionDrop(containerRef, (drop) => {
const ref = textareaRef
if (!ref || !ref.isConnected || readonly()) return false
return mention.insertDrop(drop, ref, setText, server.workspaceDirectory(), adjustHeight)
})
onCleanup(() => registerPromptMentionDrop(undefined, undefined))
})
return (
<div
ref={containerRef}
class="prompt-input-container"
classList={{ "prompt-input-container--dragging": imageAttach.dragging() }}
classList={{
"prompt-input-container--dragging": imageAttach.dragging(),
"prompt-input-container--mention-drop": promptMentionOver(),
}}
onDragOver={imageAttach.handleDragOver}
onDragLeave={imageAttach.handleDragLeave}
onDrop={(event) => {
@@ -13,7 +13,8 @@ import { useVSCode } from "../../context/vscode"
import { SessionTab } from "./SessionTab"
import { SessionTabMenu } from "./SessionTabMenu"
import { SessionTabSwitcher } from "./SessionTabSwitcher"
import { ConstrainDragYAxis, SortableTabContainer } from "./TabDnd"
import { ConstrainDragYAxis, SortableTabContainer, outsideTabBar } from "./TabDnd"
import { beginPromptMentionDrop, endPromptMentionDrop, sessionDrop } from "../../utils/prompt-mention-drop"
export const SessionTabStrip: Component = () => {
const tabs = useLocalTabs()
@@ -86,13 +87,20 @@ export const SessionTabStrip: Component = () => {
if (typeof id !== "string") return
freeze()
setDragging(id)
if (isPendingTab(id)) return
const item = items().get(id)
beginPromptMentionDrop(sessionDrop(item ?? { id }))
}
const dragOver = (event: DragEvent) => {
// Once the tab is below the bar it is on its way to the prompt, so stop
// reordering the tabs under it.
if (outsideTabBar(event)) return
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from === "string" && typeof to === "string") tabs.reorder(from, to)
}
const dragEnd = () => {
endPromptMentionDrop()
setDragging(undefined)
release()
tabs.persist()
@@ -6,14 +6,37 @@ declare module "solid-js" {
}
}
import { createSortable, useDragDropContext, type Transformer } from "@thisbeyond/solid-dnd"
import { createSortable, useDragDropContext, type Transformer, type DragEvent } from "@thisbeyond/solid-dnd"
import { createRoot, onCleanup, type Component, type ParentComponent } from "solid-js"
import { promptMentionDragging } from "../../utils/prompt-mention-drop"
/**
* True once a dragged tab has moved below the tab bar, which means it left the
* bar on the way to the prompt. Reorder must stop at that point so the tabs do
* not keep animating under the pointer.
*/
export function outsideTabBar(event: DragEvent): boolean {
return event.draggable.transformed.center.y > event.draggable.layout.bottom
}
/** Same idea for a side panel tab strip, where leaving means moving left. */
export function outsideSidePanel(event: DragEvent): boolean {
return event.draggable.transformed.center.x < event.draggable.layout.left
}
/**
* Keep tab drags in the tab bar normally, but allow a session tab to move down
* out of the bar while it is being dragged to the prompt.
*/
export const ConstrainDragYAxis: Component = () => {
const context = useDragDropContext()
if (!context) return null
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = { id: "constrain-y-axis", order: 100, callback: (value) => ({ ...value, y: 0 }) }
const transformer: Transformer = {
id: "constrain-y-axis",
order: 100,
callback: (value) => ({ ...value, y: promptMentionDragging() ? Math.max(0, value.y) : 0 }),
}
const dispose = createRoot((cleanup) => {
onDragStart(({ draggable }) => {
if (draggable) addTransformer("draggables", draggable.id as string, transformer)
@@ -24,6 +24,16 @@ export type WorktreeReference = {
disabled: boolean
}
/**
* A mention inserted directly at the caret without an open `@` query, for
* example when a session tab or worktree card is dropped on the prompt.
*/
export type PromptMentionDrop =
| { kind: "worktree"; worktree: WorktreeReference }
| { kind: "session"; session: SessionSearchItem }
| { kind: "terminal" }
| { kind: "file"; path: string }
export const PAST_CHATS_MENTION = "past-chats"
const model = {
@@ -28,8 +28,11 @@ import {
syncMentionedSessions as _syncMentionedSessions,
FILE_PICKER_RESULT,
type MentionResult,
type PromptMentionDrop,
type WorktreeReference,
} from "./file-mention-utils"
import { TERMINAL_MENTION } from "./terminal-context-utils"
import { convertToMentionPath } from "../utils/path-mentions"
const FILE_SEARCH_DEBOUNCE_MS = 150
/** Past chats offered to the ranking, bounded so chats cannot flood the list. */
@@ -145,6 +148,14 @@ export interface FileMention {
) => void
/** Insert a model reference picked from the model picker as an @-mention. */
selectModelReference: (providerID: string, modelID: string, onSelect?: () => void) => void
/** Insert a dragged reference at the caret (no open @ query). Returns true when inserted. */
insertDrop: (
drop: PromptMentionDrop,
textarea: HTMLTextAreaElement,
setText: (text: string) => void,
cwd: string,
onSelect?: () => void,
) => boolean
}
export function useFileMention(
@@ -555,6 +566,20 @@ export function useFileMention(
}
}
// Replace a textarea range through execCommand so the change lands on the
// browser's native undo stack. Restore focus first: pickers and drags can
// leave the textarea unfocused, which makes execCommand silently no-op.
const replaceRange = (textarea: HTMLTextAreaElement, start: number, end: number, value: string) => {
textarea.focus()
suppress = true
try {
textarea.setSelectionRange(start, end)
document.execCommand("insertText", false, value)
} finally {
suppress = false
}
}
const selectMention = (
result: MentionResult,
textarea: HTMLTextAreaElement,
@@ -621,17 +646,7 @@ export function useFileMention(
const atPos = match.index! + prefix
const suffix = /^\s/.test(after) ? "" : " "
remember(atPos, token)
// Restore focus before execCommand: pickers (session search, native file
// dialog) move focus away from the textarea, which makes execCommand
// silently no-op.
textarea.focus()
suppress = true
try {
textarea.setSelectionRange(atPos, cursor)
document.execCommand("insertText", false, `@${token}${suffix}`)
} finally {
suppress = false
}
replaceRange(textarea, atPos, cursor, `@${token}${suffix}`)
textarea.focus()
@@ -698,6 +713,59 @@ export function useFileMention(
// When true, onInput skips dropdown logic (used during execCommand changes)
let suppress = false
const insertToken = (
token: string,
textarea: HTMLTextAreaElement,
setText: (text: string) => void,
onSelect?: () => void,
): boolean => {
const val = textarea.value
const start = textarea.selectionStart ?? val.length
const end = textarea.selectionEnd ?? start
const before = val.substring(0, start)
const after = val.substring(end)
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
// Always leave a trailing space so the user can keep typing after a drop.
const suffix = /^\s/.test(after) ? "" : " "
replaceRange(textarea, start, end, `${prefix}@${token}${suffix}`)
// The browser fires an input event for execCommand, but tests and some edge
// paths do not, so sync from the textarea to register the mention.
syncMentionedPaths(textarea.value)
setText(textarea.value)
onSelect?.()
return true
}
const insertDrop = (
drop: PromptMentionDrop,
textarea: HTMLTextAreaElement,
setText: (text: string) => void,
cwd: string,
onSelect?: () => void,
): boolean => {
if (drop.kind === "worktree") {
if (drop.worktree.disabled) return false
// Register before execCommand so the input sync finds the path and it is
// not turned into a plain file attachment.
knownWorktrees.set(drop.worktree.path, drop.worktree)
knownPaths.add(drop.worktree.path)
return insertToken(drop.worktree.path, textarea, setText, onSelect)
}
if (drop.kind === "session") {
const normalized = { ...drop.session, title: sessionMentionText(drop.session.title) }
const token = sessionMentionToken(normalized, knownSessions)
// Register before execCommand so the input sync finds the token.
knownSessions.set(token, normalized)
return insertToken(token, textarea, setText, onSelect)
}
if (drop.kind === "terminal") return insertToken(TERMINAL_MENTION, textarea, setText, onSelect)
const resolved = convertToMentionPath(drop.path, cwd)
if (cwd) workspaceDir = cwd
// Register before execCommand so the input sync finds the path.
knownPaths.add(resolved)
return insertToken(resolved, textarea, setText, onSelect)
}
const onInput = (val: string, cursor: number) => {
syncScope()
syncMentionedPaths(val)
@@ -1038,5 +1106,6 @@ export function useFileMention(
seedSessions,
selectSession,
selectModelReference,
insertDrop,
}
}
@@ -349,6 +349,10 @@
border-style: dashed;
}
.prompt-input-container--mention-drop {
border-color: var(--border-focus, var(--vscode-focusBorder, #007fd4));
}
.prompt-input-wrapper {
display: flex;
gap: 8px;
@@ -0,0 +1,83 @@
import { createSignal, type Accessor } from "solid-js"
import type { PromptMentionDrop } from "../hooks/file-mention-utils"
export type { PromptMentionDrop }
/** Drag payload for a session tab or card. Normalizes the title and timestamp. */
export function sessionDrop(session: { id: string; title?: string; updatedAt?: string }): PromptMentionDrop {
return {
kind: "session",
session: {
id: session.id,
title: session.title?.trim() || session.id,
updated: Date.parse(session.updatedAt ?? "") || Date.now(),
},
}
}
type Target = {
element: HTMLElement
insert: (drop: PromptMentionDrop) => boolean
}
type Point = { x: number; y: number }
type Rect = { left: number; top: number; right: number; bottom: number }
let target: Target | undefined
let active: PromptMentionDrop | undefined
let point: Point | undefined
const [over, setOver] = createSignal(false)
const [dragging, setDragging] = createSignal(false)
export function insideRect(rect: Rect, x: number, y: number): boolean {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
}
const inside = (x: number, y: number) => {
const element = target?.element
if (!element || !element.isConnected) return false
const rect = element.getBoundingClientRect()
return insideRect({ left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }, x, y)
}
const move = (event: PointerEvent) => {
point = { x: event.clientX, y: event.clientY }
setOver(inside(event.clientX, event.clientY))
}
export function registerPromptMentionDrop(
element: HTMLElement | undefined,
insert: ((drop: PromptMentionDrop) => boolean) | undefined,
) {
target = element && insert ? { element, insert } : undefined
if (!element) setOver(false)
}
export function beginPromptMentionDrop(drop: PromptMentionDrop) {
if (!target) return
active = drop
point = undefined
setOver(false)
setDragging(true)
document.addEventListener("pointermove", move)
}
/** Resolve the drop. Returns true when the prompt inserted the mention. */
export function endPromptMentionDrop(): boolean {
const drop = active
if (!drop) return false
document.removeEventListener("pointermove", move)
const hit = point !== undefined && inside(point.x, point.y)
const insert = hit ? target?.insert : undefined
active = undefined
point = undefined
setOver(false)
setDragging(false)
return insert?.(drop) ?? false
}
export const promptMentionOver: Accessor<boolean> = over
/** True between a prompt mention drag start and its drag end. */
export const promptMentionDragging: Accessor<boolean> = dragging