Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/agent-manager-prompt-mention-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Mention sessions, worktrees, terminals, and open documents by dragging their tab or card into the prompt.
100 changes: 100 additions & 0 deletions packages/kilo-vscode/tests/unit/prompt-mention-drop.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
92 changes: 91 additions & 1 deletion packages/kilo-vscode/tests/unit/use-file-mention.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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("")
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -218,13 +234,22 @@ 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)
setDragging(undefined)
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
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
// 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
Expand Down
31 changes: 29 additions & 2 deletions packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
if (typeof from === "string" && typeof to === "string" && secIds().has(to)) {
props.moveToSection([from], to)
return
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
Loading
Loading