Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b8fd6be
refactor(app): drop 25-prompt rotation pool and collapse placeholder
Astro-Han May 17, 2026
54dc659
feat(app): add home suggestion settings keys and toggle
Astro-Han May 17, 2026
c75c16c
feat(app): add pure helper resolving visible home suggestion chips
Astro-Han May 17, 2026
678dcf5
feat(app): add home suggestion list component
Astro-Han May 17, 2026
82b6ba4
feat(app): render HomeSuggestionList under the home composer
Astro-Han May 17, 2026
00934d3
test(app): e2e for home suggestion chips onboarding
Astro-Han May 17, 2026
9366f4d
fix(app): harden home suggestion chips per code crosscheck
Astro-Han May 17, 2026
8261193
fix(app): per-row dismiss must not mark home onboarding seen
Astro-Han May 17, 2026
72cc3fa
fix(app): settings restore must reset BOTH dismissed and seen
Astro-Han May 17, 2026
7c68bf5
fix(app): hide restore button when sessions exist, preserve dirty com…
Astro-Han May 17, 2026
f758218
fix(app): defer markSeen until prefill actually accepts the chip
Astro-Han May 17, 2026
98ef3b1
fix(app): settings page must not depend on Sync provider
Astro-Han May 17, 2026
2a147f7
refactor(app): collapse home onboarding to 3 rows with per-row dismis…
Astro-Han May 17, 2026
72adb71
style(app): tighten home suggestion list visuals per design review
Astro-Han May 17, 2026
cd954ce
style(app): align home suggestion rows with picker-family DNA
Astro-Han May 17, 2026
8e58682
refactor(home): inset rows and add breathing gap to suggestion list
Astro-Han May 17, 2026
7fd6265
feat(home): split chip label and prefill prompt, refocus tasks on Off…
Astro-Han May 17, 2026
72432b7
fix(home): let chip clicks always replace composer content
Astro-Han May 17, 2026
9452994
feat(home): track chip source, auto-dismiss on send for capability di…
Astro-Han May 17, 2026
3895bd6
fix(home): decouple chip visibility from per-project session count
Astro-Han May 17, 2026
4170dce
feat(prompt): tell agents about bundled officecli
Astro-Han May 17, 2026
20bc61d
fix(shell): prepend bundled tools dir to PATH for shell mode and PTY
Astro-Han May 17, 2026
65ed18e
fix(env): drop trailing PATH delimiter when currentPath is empty
Astro-Han May 17, 2026
6608d6b
fix(a11y): keep home suggestion dismiss button keyboard-reachable
Astro-Han May 17, 2026
a24f840
fix(e2e): read language from the real LanguageProvider storage key
Astro-Han May 17, 2026
c416e5e
perf(home): defer suggestion list mount and drop dirty() subscription
Astro-Han May 17, 2026
a6b8ed5
fix(home): also gate suggestion visibility on settings.ready()
Astro-Han May 17, 2026
9116d6e
fix(i18n): rename home suggestion dismiss copy to "suggestion"
Astro-Han May 17, 2026
a78a35c
fix(env): resolve PATH case-insensitively and strip duplicates on merge
Astro-Han May 17, 2026
e72fda2
chore(e2e): conform home suggestion spec to coding conventions
Astro-Han May 17, 2026
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
230 changes: 230 additions & 0 deletions packages/app/e2e/onboarding/home-suggestion-chips.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"

const SUGGESTION_LIST_SELECTOR = '[data-component="home-suggestion-list"]'
const ROW_SELECTOR = '[data-action="home-suggestion-row"]'
const ROW_DISMISS_SELECTOR = '[data-action="home-suggestion-row-dismiss"]'

async function readDismissedFromStorage(page: Page): Promise<string[]> {
// settings.v3 is persisted via the settings store (utils/persist.ts) — in the
// e2e web context this hits localStorage directly with key "settings.v3".
return await page.evaluate(() => {
try {
const raw = localStorage.getItem("settings.v3")
if (!raw) return []
const parsed = JSON.parse(raw)
return parsed?.general?.homeSuggestionsDismissed ?? []
} catch {
return []
}
})
}

test("@smoke home shows 3 suggestion rows for a first-time visitor", async ({ page, project }) => {
await project.open()

const list = page.locator(SUGGESTION_LIST_SELECTOR)
await expect(list).toBeVisible()
await expect(list.locator(ROW_SELECTOR)).toHaveCount(3)
})

test("@smoke clicking a suggestion row prefills the composer", async ({ page, project }) => {
await project.open()

const list = page.locator(SUGGESTION_LIST_SELECTOR)
const firstRow = list.locator(ROW_SELECTOR).first()
const text = (await firstRow.innerText()).trim()
await firstRow.click()

const editor = page.locator(promptSelector)
await expect(editor).toBeFocused()
// contenteditable rendered via renderEditorWithCursor may include zero-width
// chars or wrapping spans, so toHaveText (strict) is flaky. toContainText
// asserts substring after textContent normalization which is right here.
await expect(editor).toContainText(text)
})

test("@smoke per-row X dismisses one row and persists across reload", async ({ page, project }) => {
await project.open()

const list = page.locator(SUGGESTION_LIST_SELECTOR)
await expect(list.locator(ROW_SELECTOR)).toHaveCount(3)

const firstRow = list.locator(ROW_SELECTOR).first()
await firstRow.hover()
await list.locator(ROW_DISMISS_SELECTOR).first().click()

await expect(list.locator(ROW_SELECTOR)).toHaveCount(2)

await page.reload()
await expect(page.locator(SUGGESTION_LIST_SELECTOR).locator(ROW_SELECTOR)).toHaveCount(2)
})

test("@smoke composer placeholder is the static home string", async ({ page, project }) => {
await project.open()

const editor = page.locator(promptSelector)
await expect(editor).toBeVisible()

// LanguageProvider persists under "pawwork.global.dat:language" with shape
// { locale: "zh" | "en" } (see packages/app/src/context/language.tsx). Falls
// back to "en" when unset, matching detectLocale()'s final return in a
// CI runner where navigator.language is "en-US".
const locale = await page.evaluate(() => {
const raw = localStorage.getItem("pawwork.global.dat:language")
if (!raw) return "en"
try {
const parsed = JSON.parse(raw) as { locale?: string }
return parsed.locale?.startsWith?.("zh") ? "zh" : "en"
} catch {
return "en"
}
})
const label = await editor.getAttribute("aria-label")
// i18n source: packages/app/src/i18n/{zh,en}.ts → prompt.placeholder.home
if (locale === "zh") {
expect(label).toBe("输入你的任务,或 @ 引用文件")
} else {
expect(label).toBe("Type your task, or @ to mention files")
}
})

test("dismissing all 3 rows hides the section entirely", async ({ page, project }) => {
await project.open()
const list = page.locator(SUGGESTION_LIST_SELECTOR)
await expect(list.locator(ROW_SELECTOR)).toHaveCount(3)

for (let i = 0; i < 3; i++) {
const firstRow = list.locator(ROW_SELECTOR).first()
await firstRow.hover()
await list.locator(ROW_DISMISS_SELECTOR).first().click()
}

await expect(page.locator(SUGGESTION_LIST_SELECTOR)).toHaveCount(0)

await page.reload()
await expect(page.locator(SUGGESTION_LIST_SELECTOR)).toHaveCount(0)
})

test("used chip is gone but unused chips still appear on home after session creation", async ({
page,
project,
assistant,
}) => {
await project.open()
await assistant.reply("unused chips remain reply")

const list = page.locator(SUGGESTION_LIST_SELECTOR)
const rows = list.locator(ROW_SELECTOR)
await expect(rows).toHaveCount(3)

const firstChipID = await rows.first().getAttribute("data-chip-id")
expect(firstChipID).toBeTruthy()

await rows.first().click()
await page.keyboard.press("Enter")
await expect.poll(() => page.url(), { timeout: 30_000 }).toContain("/session/")

// Back to home: chips are NOT gated by sessionCount, so the unused two remain.
await project.open()
await expect(list.locator(ROW_SELECTOR)).toHaveCount(2)
const remainingIDs = await list
.locator(ROW_SELECTOR)
.evaluateAll((els) => els.map((el) => el.getAttribute("data-chip-id")))
expect(remainingIDs).not.toContain(firstChipID)
})

test("editing a prefilled suggestion preserves the user's edit on send", async ({ page, project, assistant }) => {
await project.open()
await assistant.reply("edited reply")

const editor = page.locator(promptSelector)
await page.locator(SUGGESTION_LIST_SELECTOR).locator(ROW_SELECTOR).first().click()
await expect(editor).toBeFocused()

await page.keyboard.type(" please be concise")
await page.keyboard.press("Enter")

await expect.poll(() => page.url(), { timeout: 30_000 }).toContain("/session/")
await expect(page.getByText(/please be concise/)).toBeVisible()
})

test("clicking another suggestion replaces the previous prefill", async ({ page, project }) => {
await project.open()

const list = page.locator(SUGGESTION_LIST_SELECTOR)
const editor = page.locator(promptSelector)
const rows = list.locator(ROW_SELECTOR)

const firstText = (await rows.first().innerText()).trim()
const secondText = (await rows.nth(1).innerText()).trim()

await rows.first().click()
await expect(editor).toContainText(firstText)

await rows.nth(1).click()
await expect(editor).toContainText(secondText)
await expect(editor).not.toContainText(firstText)
})

test("using a chip via send auto-dismisses it for capability discovery", async ({ page, project, assistant }) => {
await project.open()
await assistant.reply("auto-dismiss reply")

const firstRow = page.locator(SUGGESTION_LIST_SELECTOR).locator(ROW_SELECTOR).first()
const firstChipID = await firstRow.getAttribute("data-chip-id")
expect(firstChipID).toBeTruthy()

await firstRow.click()
await page.keyboard.press("Enter")
await expect.poll(() => page.url(), { timeout: 30_000 }).toContain("/session/")

const dismissed = await readDismissedFromStorage(page)
expect(dismissed).toContain(firstChipID!)
Comment thread
Astro-Han marked this conversation as resolved.
})

test("switching chips before send dismisses only the last selection", async ({ page, project, assistant }) => {
await project.open()
await assistant.reply("only-last reply")

const rows = page.locator(SUGGESTION_LIST_SELECTOR).locator(ROW_SELECTOR)
const firstChipID = await rows.first().getAttribute("data-chip-id")
const secondChipID = await rows.nth(1).getAttribute("data-chip-id")

await rows.first().click()
await rows.nth(1).click()
await page.keyboard.press("Enter")
await expect.poll(() => page.url(), { timeout: 30_000 }).toContain("/session/")

const dismissed = await readDismissedFromStorage(page)
expect(dismissed).toContain(secondChipID!)
expect(dismissed).not.toContain(firstChipID!)
})

test("clicking a chip then sending any content dismisses it (sticky source)", async ({ page, project, assistant }) => {
await project.open()
await assistant.reply("sticky source reply")

const editor = page.locator(promptSelector)
const firstRow = page.locator(SUGGESTION_LIST_SELECTOR).locator(ROW_SELECTOR).first()
const firstChipID = await firstRow.getAttribute("data-chip-id")
expect(firstChipID).toBeTruthy()

await firstRow.click()
await expect(editor).toBeFocused()

// Drain the prefill, then type user-authored content. The chip source is
// sticky once clicked — sending any prompt afterwards still dismisses it
// (the user engaged with the suggestion and chose a direction, no need to
// keep pitching it).
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("my own prompt content")
await page.keyboard.press("Enter")
await expect.poll(() => page.url(), { timeout: 30_000 }).toContain("/session/")

const dismissed = await readDismissedFromStorage(page)
expect(dismissed).toContain(firstChipID!)
})
95 changes: 95 additions & 0 deletions packages/app/src/components/home/home-suggestion-list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"

describe("HomeSuggestionList source contract", () => {
const source = readFileSync("src/components/home/home-suggestion-list.tsx", "utf8")

test("wires the helper, prompt, settings, sync, and language contexts", () => {
expect(source).toContain("resolveVisibleHomeSuggestions")
expect(source).toContain("usePrompt")
expect(source).toContain("useSettings")
expect(source).toContain("useSync")
expect(source).toContain("useLanguage")
})

test("observes sessionCount only for the auto-dismiss effect, not as visibility gate", () => {
expect(source).toContain("sync.data.session")
expect(source).toMatch(/sync\.data\.session\??\.length/)
// BOTH stores must gate visibility on desktop because sync and settings
// are independent async hydrations; reading dismissed before settings is
// ready returns the withFallback([]) default and re-shows dismissed chips.
expect(source).toContain("sync.ready")
expect(source).toContain("settings.ready()")
// No per-project gate: visibility no longer derives from sessionCount === 0.
expect(source).not.toMatch(/firstTimeVisitor/)
})

test("exposes the documented data-component and data-action hooks for E2E", () => {
expect(source).toContain('data-component="home-suggestion-list"')
expect(source).toContain('data-action="home-suggestion-row"')
expect(source).toContain('data-action="home-suggestion-row-dismiss"')
})

test("does NOT render a section-level dismiss (only per-row X remains)", () => {
expect(source).not.toContain("home-suggestion-section-dismiss")
expect(source).not.toContain("home.suggestion.section")
})

test("does NOT couple to homeSuggestionsEnabled or homeSuggestionsSeen (those were removed)", () => {
expect(source).not.toContain("homeSuggestionsEnabled")
expect(source).not.toContain("homeSuggestionsSeen")
expect(source).not.toContain("setHomeSuggestionsSeen")
})

test("prefills the composer via prompt.set and focuses the editor", () => {
expect(source).toContain("prompt.set([")
expect(source).toContain('[data-component="prompt-input"]')
})

test("explicitly restores caret position after focus so follow-up typing works deterministically", () => {
expect(source).toContain("setCursorPosition")
})

test("prefill unconditionally replaces composer content (no dirty-guard skip)", () => {
// Chip clicks must always overwrite so the click-A-then-B exploration works.
// The previous "respect user-typed content" guard was replaced by per-chip
// currentChipSource tracking + auto-dismiss on send (see spec § Chip Lifecycle).
expect(source).not.toMatch(/if \(prompt\.dirty\(\)\)\s*\{[\s\S]{0,200}return/)
})

test("tracks chip source and auto-dismisses on session create", () => {
expect(source).toContain("currentChipSource")
expect(source).toContain("setCurrentChipSource")
expect(source).toMatch(/sessionCount\(\)/)
expect(source).toMatch(/dismissRow\(source\)/)
})

test("filters dismissed IDs against known chip IDs (no bare type cast)", () => {
expect(source).toContain("filterKnownIDs")
expect(source).not.toMatch(/homeSuggestionsDismissed\(\) as HomeSuggestionChipID\[\]/)
})

test("uses the settings accessor for read and write (not raw store)", () => {
expect(source).toContain("settings.general.homeSuggestionsDismissed()")
expect(source).toContain("settings.general.setHomeSuggestionsDismissed(")
expect(source).not.toContain("settings.store.general")
expect(source).not.toContain("settings.setStore(")
})

test("rest-state dismiss button is not clickable (pointer-events-none) but stays keyboard-reachable", () => {
expect(source).toContain("pointer-events-none")
expect(source).toContain("group-hover:pointer-events-auto")
expect(source).toContain("focus-visible:pointer-events-auto")
// Must NOT be excluded from the tab order: keyboard-only users need a path
// to dismiss a chip. Reveal-on-focus handles the visual hiding.
expect(source).not.toContain("tabIndex={-1}")
})

test("renders nothing when there are no visible chips", () => {
expect(source).toContain("visibleChips().length > 0")
})

test("uses i18n keys for chip text and aria-label", () => {
expect(source).toContain("home.suggestion.row.dismiss")
})
})
Loading
Loading