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
82 changes: 79 additions & 3 deletions packages/app/e2e/prompt/prompt-slash-open.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,54 @@
import { mkdir, writeFile } from "node:fs/promises"
import { join } from "node:path"
import type { Locator } from "@playwright/test"
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"

async function expectOnTop(locator: Locator) {
await expect(locator).toBeVisible()
await expect
.poll(async () => {
return locator.evaluate((el) => {
const rect = el.getBoundingClientRect()
const target = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
return !!target && (target === el || el.contains(target))
})
})
.toBe(true)
}

async function expectViewportGap(locator: Locator) {
await expect
.poll(async () => {
return locator.evaluate((el) => el.getBoundingClientRect().top)
})
.toBeGreaterThanOrEqual(8)
}

async function expectHoverPaint(locator: Locator) {
await locator.hover()
await expect
.poll(async () => {
return locator.evaluate((el) => getComputedStyle(el).backgroundColor)
})
.not.toBe("rgba(0, 0, 0, 0)")
}

test("/open opens file picker dialog", async ({ page, gotoSession }) => {
await gotoSession()

await page.locator(promptSelector).click()
await page.keyboard.type("/open")
await page.keyboard.type("/")

const popover = page.locator('[data-component="prompt-slash-popover"]')
await expectOnTop(popover)
await expectViewportGap(popover)

await page.keyboard.type("open")

const command = page.locator('[data-slash-id="file.open"]')
await expect(command).toBeVisible()
await command.hover()
await expectOnTop(command)
await expectHoverPaint(command)

await page.keyboard.press("Enter")

Expand All @@ -20,3 +59,40 @@ test("/open opens file picker dialog", async ({ page, gotoSession }) => {
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})

test("home composer shows slash commands after a bare slash", async ({ page, project }) => {
await project.open()

await page.locator(promptSelector).click()
await page.keyboard.type("/")

const popover = page.locator('[data-component="prompt-slash-popover"]')
await expectOnTop(popover)
await expectViewportGap(popover)
const command = page.locator('[data-slash-id="file.open"]')
await expectOnTop(command)
await expectHoverPaint(command)
})

test("@mention file suggestions stay visible above the composer", async ({ page, project }) => {
await project.open({
setup: async (directory) => {
await mkdir(join(directory, "src"), { recursive: true })
await writeFile(join(directory, "src", "mention-target.md"), "hello")
},
})

await page.locator(promptSelector).click()
const sep = process.platform === "win32" ? "\\" : "/"
const file = ["src", "mention-target.md"].join(sep)
const filePattern = /src[\\/]+\s*mention-target\.md/

await page.keyboard.type(`@${file}`)

const popover = page.locator('[data-component="prompt-at-popover"]')
await expectOnTop(popover)

const suggestion = page.getByRole("button", { name: filePattern }).first()
await expectOnTop(suggestion)
await expectHoverPaint(suggestion)
})
8 changes: 4 additions & 4 deletions packages/app/src/components/prompt-input/slash-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
ref={(el) => {
if (props.popover === "slash") props.setSlashPopoverRef(el)
}}
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
class="absolute inset-x-0 -top-2 -translate-y-full z-50 origin-bottom-left max-h-80 min-h-10
pointer-events-auto overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
bg-surface-raised shadow-[var(--shadow-lg-border-base)]"
onMouseDown={(e) => e.preventDefault()}
>
Expand All @@ -60,7 +60,7 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
return (
<button
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ "bg-surface-raised": props.atActive === key }}
classList={{ "[background:var(--row-hover-overlay)]": props.atActive === key }}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(key)}
>
Expand Down Expand Up @@ -88,7 +88,7 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
data-slash-id={cmd.id}
classList={{
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
"bg-surface-raised": props.slashActive === cmd.id,
"[background:var(--row-hover-overlay)]": props.slashActive === cmd.id,
}}
onClick={() => props.onSlashSelect(cmd)}
onMouseEnter={() => props.setSlashActive(cmd.id)}
Expand Down
169 changes: 84 additions & 85 deletions packages/app/src/pages/session/composer/session-composer-region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,93 +179,92 @@ export function SessionComposerRegion(props: {
</DockCard>
}
>
<DockCard>
<Show when={dockMounted()}>
<SessionTodoDock
sessionID={displaySessionID()}
todos={props.state.todos()}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={dockProgress()}
/>
</Show>
<Show when={rolled()} keyed>
{(revert) => (
<SessionRevertDock
items={revert.items}
restoring={revert.restoring}
disabled={revert.disabled}
onRestore={revert.onRestore}
<div class="relative z-30">
<DockCard class="overflow-visible!">
<Show when={dockMounted()}>
<SessionTodoDock
sessionID={displaySessionID()}
todos={props.state.todos()}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={dockProgress()}
/>
)}
</Show>
<Show when={props.followup?.items.length}>
<SessionFollowupDock
items={props.followup!.items}
sending={props.followup!.sending}
onSend={props.followup!.onSend}
onEdit={props.followup!.onEdit}
/>
</Show>
<Show
when={props.state.permissionRequest()}
keyed
fallback={
<Show
when={child()}
fallback={
<Show when={!props.state.blocked()}>
<PromptInput
ref={props.inputRef}
homeMode={home()}
sessionID={displaySessionID()}
sessionIDControlled={!home()}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
edit={props.followup?.edit}
onEditLoaded={props.followup?.onEditLoaded}
shouldQueue={props.followup?.queue}
onQueue={props.followup?.onQueue}
onAbort={props.followup?.onAbort}
onSubmit={props.onSubmit}
onModeChange={props.onModeChange}
actionReady={() => props.actionReady ?? true}
abortReady={() => props.abortReady ?? props.actionReady ?? true}
selectedSkill={props.selectedSkill}
/>
</Show>
}
>
<DockSegment
ref={props.inputRef as (el: HTMLDivElement) => void}
class="w-full p-3 text-16-regular text-fg-weak"
>
<span>{language.t("session.child.promptDisabled")} </span>
<Show when={parentID()}>
<button
type="button"
class="text-fg-base transition-colors hover:text-fg-strong"
onClick={openParent}
>
{language.t("session.child.backToParent")}
</button>
</Show>
</DockSegment>
</Show>
}
>
{(request) => (
<SessionPermissionContent
request={request}
responding={props.state.permissionResponding()}
onDecide={(response) => {
props.onResponseSubmit()
props.state.decide(response)
}}
</Show>
<Show when={rolled()} keyed>
{(revert) => (
<SessionRevertDock
items={revert.items}
restoring={revert.restoring}
disabled={revert.disabled}
onRestore={revert.onRestore}
/>
)}
</Show>
<Show when={props.followup?.items.length}>
<SessionFollowupDock
items={props.followup!.items}
sending={props.followup!.sending}
onSend={props.followup!.onSend}
onEdit={props.followup!.onEdit}
/>
)}
</Show>
</DockCard>
</Show>
<Show
when={props.state.permissionRequest()}
keyed
fallback={
<Show
when={child()}
fallback={
<Show when={!props.state.blocked()}>
<PromptInput
ref={props.inputRef}
homeMode={home()}
sessionID={displaySessionID()}
sessionIDControlled={!home()}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
edit={props.followup?.edit}
onEditLoaded={props.followup?.onEditLoaded}
shouldQueue={props.followup?.queue}
onQueue={props.followup?.onQueue}
onAbort={props.followup?.onAbort}
onSubmit={props.onSubmit}
onModeChange={props.onModeChange}
actionReady={() => props.actionReady ?? true}
abortReady={() => props.abortReady ?? props.actionReady ?? true}
selectedSkill={props.selectedSkill}
/>
</Show>
}
>
<DockSegment ref={props.inputRef} class="w-full p-3 text-16-regular text-fg-weak">
<span>{language.t("session.child.promptDisabled")} </span>
<Show when={parentID()}>
<button
type="button"
class="text-fg-base transition-colors hover:text-fg-strong"
onClick={openParent}
>
{language.t("session.child.backToParent")}
</button>
</Show>
</DockSegment>
</Show>
}
>
{(request) => (
<SessionPermissionContent
request={request}
responding={props.state.permissionResponding()}
onDecide={(response) => {
props.onResponseSubmit()
props.state.decide(response)
}}
/>
)}
</Show>
</DockCard>
</div>
</Show>
</Show>
</div>
Expand Down
Loading