mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
feat(agent-manager): drag sessions, worktrees, terminals, and documents into the prompt
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user