Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
117 changes: 117 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,117 @@
import { afterEach, describe, expect, it } from "bun:test"
import {
beginPromptMentionDrop,
endPromptMentionDrop,
insideRect,
promptMentionDragging,
promptMentionOver,
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)
})

it("tears down an active drag when the prompt unmounts", () => {
mockDocument()
registerPromptMentionDrop(target(), () => true)
beginPromptMentionDrop(drop)
expect(listeners.size).toBe(1)
expect(promptMentionDragging()).toBe(true)

registerPromptMentionDrop(undefined, undefined)

expect(listeners.size).toBe(0)
expect(promptMentionDragging()).toBe(false)
expect(promptMentionOver()).toBe(false)
expect(endPromptMentionDrop()).toBe(false)
})
})
100 changes: 99 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,96 @@ 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("keeps the dropdown closed while typing after a dropped mention", () => {
withMention("", undefined, (mention, area) => {
mention.insertDrop({ kind: "terminal" }, area, () => {}, "")
mention.onInput("@terminal what failed", 21)
expect(mention.showMention()).toBe(false)
})
})

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 @@ -8,6 +8,7 @@ import {
} 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 { 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,19 @@ 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. Use the strip bounds, not the tab's own
// rect, so leftward reordering inside the strip still works.
if (props.drag && event.draggable.transformed.center.x < host.getBoundingClientRect().left) 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,25 @@ 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 })),
wt.id === props.selection || store.staleWorktreeIds().has(wt.id) || store.busy().has(wt.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 +235,21 @@ 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. Both this path and an
// outside release undo the reorder applied while passing over sibling rows.
if (handled || outsideSidebar(event.draggable)) {
if (origin) store.setWorktreeOrder(origin)
return
}
if (!from || !worktreeIds().has(from)) {
if (origin) store.setWorktreeOrder(origin)
return
Expand Down
Loading
Loading