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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { render } from "solid-js/web"
import { SessionOpeningSkeleton } from "@/pages/session/session-opening-skeleton"

export function mountSessionOpeningSkeletonFixture(target: HTMLElement) {
target.innerHTML = ""
target.className = "min-h-screen bg-bg-base text-fg-base"

const shell = document.createElement("div")
shell.className = "relative mx-auto flex h-[720px] max-w-[1040px] flex-col overflow-hidden border-x border-border-weak"
target.append(shell)

const header = document.createElement("div")
header.className = "h-12 shrink-0 border-b border-border-weak bg-bg-base"
shell.append(header)

const body = document.createElement("div")
body.className = "relative min-h-0 flex-1"
shell.append(body)

render(
() => <SessionOpeningSkeleton visible={true} transitioning={true} openingLabel="Opening session..." />,
body,
)
}
44 changes: 44 additions & 0 deletions packages/app/e2e/snap/session-opening-skeleton.snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { expect } from "@playwright/test"
import { fileURLToPath } from "node:url"
import { test } from "../fixtures"
import { composeGrid, snapOutputPath, type Shot } from "./_compose"

test.use({ viewport: { width: 1280, height: 820 }, deviceScaleFactor: 2 })

const fixturePath = fileURLToPath(new URL("./fixtures/session-opening-skeleton-fixture.tsx", import.meta.url))

async function waitForThemeBoot(page: import("@playwright/test").Page): Promise<void> {
await page.waitForFunction(
() => getComputedStyle(document.documentElement).getPropertyValue("--bg-base").trim().length > 0,
null,
{ timeout: 30_000 },
)
}

async function capture(page: import("@playwright/test").Page, name: string): Promise<Shot> {
const root = page.locator('[data-component="session-opening-state"]')
await expect(root).toBeVisible({ timeout: 30_000 })
await expect(root.locator(".animate-spin")).toHaveCount(0)
await expect(root.locator("button")).toHaveCount(0)
return { name, buf: await page.locator("body").screenshot() }
}

test("session-opening-skeleton", async ({ page }) => {
test.setTimeout(180_000)

await page.goto("/")
await waitForThemeBoot(page)
await page.evaluate(async (path) => {
const mod = await import(path)
mod.mountSessionOpeningSkeletonFixture(document.body)
}, `/@fs/${fixturePath}`)

const desktop = await capture(page, "desktop")

await page.setViewportSize({ width: 768, height: 820 })
const narrow = await capture(page, "narrow")

const out = snapOutputPath("session-opening-skeleton")
await composeGrid([desktop, narrow], out)
process.stdout.write(`\n[snap] session-opening-skeleton grid -> ${out}\n\n`)
})
26 changes: 11 additions & 15 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)
}

if (!actionReady()) return language.t("prompt.loading")

return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
Expand Down Expand Up @@ -198,13 +196,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)

const placeholder = createMemo(() =>
actionReady()
? promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
t: (key) => language.t(key as Parameters<typeof language.t>[0]),
})
: language.t("prompt.loading"),
promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
t: (key) => language.t(key as Parameters<typeof language.t>[0]),
}),
)

const pick = () => {
Expand All @@ -214,6 +210,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
openFilePickerDialog: canUseNativeFilePicker(platform) ? openFilePickerDialog : undefined,
addPickedPaths,
fallbackInputClick: () => fileInputRef?.click(),
isReady: actionReady,
})
}

Expand Down Expand Up @@ -387,6 +384,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
imageAttachments,
composing,
sync,
externalReady: actionReady,
})

const accepting = createMemo(() => {
Expand Down Expand Up @@ -478,7 +476,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
classList={{
"group/prompt-input @container/composer": true,
"border-fg-base border-dashed": store.draggingType !== null,
"opacity-75": !actionReady(),
[props.class ?? ""]: !!props.class,
}}
>
Expand Down Expand Up @@ -535,8 +532,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
role="textbox"
aria-multiline="true"
aria-label={placeholder()}
aria-disabled={!actionReady()}
contenteditable={actionReady() ? "true" : "false"}
contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
Expand All @@ -546,7 +542,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onInput={handleInput}
onCopy={handleCopy}
onPaste={(event) => {
if (!actionReady()) {
const hasFiles = Array.from(event.clipboardData?.items ?? []).some((item) => item.kind === "file")
if (!actionReady() && hasFiles) {
event.preventDefault()
return
}
Comment thread
Astro-Han marked this conversation as resolved.
Expand All @@ -562,7 +559,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
"cursor-wait text-fg-weak": !actionReady(),
}}
style={{ "padding-bottom": space }}
/>
Expand Down Expand Up @@ -596,7 +592,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="hidden"
onChange={(e) => {
const list = e.currentTarget.files
if (list) void addAttachments(Array.from(list))
if (list && actionReady()) void addAttachments(Array.from(list))
e.currentTarget.value = ""
}}
/>
Expand Down
70 changes: 70 additions & 0 deletions packages/app/src/components/prompt-input/attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ mock.module("@/context/language", () => ({
}))

mock.module("@/context/prompt", () => ({
DEFAULT_PROMPT: [{ type: "text", content: "", start: 0, end: 0 }],
isStructurallyEmpty: (parts: unknown[], contextItems: unknown[], imageAttachments: unknown[]) =>
(parts.length === 0 ||
(parts.length === 1 &&
typeof parts[0] === "object" &&
parts[0] !== null &&
"type" in parts[0] &&
parts[0].type === "text" &&
"content" in parts[0] &&
parts[0].content === "")) &&
contextItems.length === 0 &&
imageAttachments.length === 0,
usePrompt: () => ({
current: () => promptParts,
cursor: () => 0,
Expand Down Expand Up @@ -285,6 +297,64 @@ describe("createPromptAttachments", () => {
expect(toasts).toHaveLength(0)
})

test("handleGlobalDrop cancels native drop before bailing when externalReady is false", async () => {
const attachments = createPromptAttachments({
editor: () => ({}) as HTMLDivElement,
isDialogActive: () => false,
setDraggingType: () => undefined,
focusEditor: () => undefined,
addPart: () => true,
model: () => ({ capabilities: { input: { image: true } } }),
openModelSelector: () => undefined,
externalReady: () => false,
})

let prevented = false
const fakeEvent = {
preventDefault: () => {
prevented = true
},
dataTransfer: { files: [], getData: () => "" },
} as unknown as DragEvent

await attachments.handleGlobalDrop(fakeEvent)

expect(prevented).toBe(true)
expect(promptParts).toHaveLength(0)
})

test("handlePaste skips native clipboard image when externalReady is false", async () => {
let readCalls = 0
const attachments = createPromptAttachments({
editor: () => ({}) as HTMLDivElement,
isDialogActive: () => false,
setDraggingType: () => undefined,
focusEditor: () => undefined,
addPart: () => true,
model: () => ({ capabilities: { input: { image: true } } }),
openModelSelector: () => undefined,
externalReady: () => false,
readClipboardImage: async () => {
readCalls++
return new File(["image"], "screenshot.png", { type: "image/png" })
},
})

const fakeEvent = {
preventDefault: () => undefined,
stopPropagation: () => undefined,
clipboardData: {
items: [],
getData: () => "",
},
} as unknown as ClipboardEvent

await attachments.handlePaste(fakeEvent)

expect(readCalls).toBe(0)
expect(promptParts).toHaveLength(0)
})

test("reports direct attachment read failures in dropped file batches", async () => {
fileReaderDataUrl = "data:;base64,not-base64"
const attachments = createPromptAttachments({
Expand Down
14 changes: 14 additions & 0 deletions packages/app/src/components/prompt-input/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ type PromptAttachmentsInput = {
imageAttachments?: () => readonly ImageAttachmentPart[]
composing?: () => boolean
sync?: ReturnType<typeof useSync>
externalReady?: () => boolean
}

export function createPromptAttachments(input: PromptAttachmentsInput) {
Expand Down Expand Up @@ -270,6 +271,10 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {

// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
if (input.readClipboardImage && !plainText) {
// Same readiness gate as the synchronous onPaste check: a screenshot
// paste reaches here without showing up as a `file` clipboard item, so
// the upstream check can't see it.
if (input.externalReady && !input.externalReady()) return
const file = await input.readClipboardImage()
if (file) {
await addAttachment(file)
Expand Down Expand Up @@ -320,6 +325,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {

const handleGlobalDragOver = (event: DragEvent) => {
if (input.isDialogActive()) return
if (input.externalReady && !input.externalReady()) return

event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files")
Expand All @@ -340,6 +346,13 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {

const handleGlobalDrop = async (event: DragEvent) => {
if (input.isDialogActive()) return
if (input.externalReady && !input.externalReady()) {
// Cancel the native drop so Electron doesn't open the file or navigate
// away from the app while the session is still opening.
event.preventDefault()
input.setDraggingType(null)
return
}

event.preventDefault()
input.setDraggingType(null)
Expand Down Expand Up @@ -375,5 +388,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
addPickedPaths,
removeAttachment,
handlePaste,
handleGlobalDrop,
}
}
28 changes: 23 additions & 5 deletions packages/app/src/components/prompt-input/keydown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import type { SetStoreFunction } from "solid-js/store"
import type { ContentPart, TextPart, usePrompt } from "@/context/prompt"
import { canNavigateHistoryAtCursor } from "./history"
import { getCursorPosition } from "./editor-dom"
import { promptKeyActionReady } from "./readiness"
import {
promptKeyActionReady,
shouldActivateShellModeFromBang,
shouldExitShellModeOnBackspace,
} from "./readiness"
import type { PromptStore } from "./store-types"
import { computeCommandBackspaceResult } from "./command-backspace"

Expand Down Expand Up @@ -126,9 +130,15 @@ export function createPromptKeydownHandler(deps: PromptKeydownDeps): (event: Key
}
}

if (event.key === "!" && store.mode === "normal") {
if (event.key === "!") {
const cursorPosition = getCursorPosition(editorRef())
if (cursorPosition === 0) {
if (
shouldActivateShellModeFromBang({
cursorPosition,
mode: store.mode,
actionReady: actionReady(),
})
) {
setStore("mode", "shell")
setStore("popover", null)
event.preventDefault()
Expand Down Expand Up @@ -166,9 +176,17 @@ export function createPromptKeydownHandler(deps: PromptKeydownDeps): (event: Key
}
}

if (store.mode === "shell") {
if (store.mode === "shell" && event.key === "Backspace") {
const { collapsed, cursorPosition, textLength } = getCaretState()
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
if (
shouldExitShellModeOnBackspace({
mode: store.mode,
collapsed,
cursorPosition,
textLength,
actionReady: actionReady(),
})
) {
setStore("mode", "normal")
event.preventDefault()
return
Expand Down
21 changes: 21 additions & 0 deletions packages/app/src/components/prompt-input/pick-attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,25 @@ describe("pickAttachments", () => {
expect(result).toBe(false)
expect(error).toHaveBeenCalled()
})

test("skips addPickedPaths when readiness flips during the picker dialog", async () => {
let ready = true
let called = false

const result = await pickAttachments({
openFilePickerDialog: async () => {
ready = false
return "/tmp/report.docx"
},
addPickedPaths: async () => {
called = true
return true
},
fallbackInputClick: () => {},
isReady: () => ready,
})

expect(result).toBe(false)
expect(called).toBe(false)
})
})
5 changes: 5 additions & 0 deletions packages/app/src/components/prompt-input/pick-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export async function pickAttachments(input: {
openFilePickerDialog?: Platform["openFilePickerDialog"]
addPickedPaths: (paths: string[]) => Promise<boolean>
fallbackInputClick: () => void
isReady?: () => boolean
}) {
if (!input.openFilePickerDialog) {
input.fallbackInputClick()
Expand All @@ -22,6 +23,10 @@ export async function pickAttachments(input: {
}
if (!result) return false

// Readiness can flip while the native dialog is open. Re-check before
// mutating the prompt so opening sessions don't accept stale picks.
if (input.isReady && !input.isReady()) return false

const paths = (Array.isArray(result) ? result : [result]).filter((path) => path.length > 0)
if (paths.length === 0) return false
try {
Expand Down
Loading
Loading