From b8fd6be2026352307d30d229fe618efd561a6584 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 15:15:29 +0800 Subject: [PATCH 01/30] refactor(app): drop 25-prompt rotation pool and collapse placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove EXAMPLES array, 6.5s setInterval rotation, and random initial index from prompt-input. promptPlaceholder() loses the suggest and example parameters, returning a single static prompt.placeholder.home key for the default branch. 25 prompt.example.* i18n keys and the now- unused prompt.placeholder.normal / prompt.placeholder.simple keys are removed. Part of the first-time-visitor onboarding redesign — see docs/superpowers/specs/2026-05-17-onboarding-design.md --- packages/app/src/components/prompt-input.tsx | 55 +------------------ .../prompt-input/placeholder.test.ts | 41 ++------------ .../components/prompt-input/placeholder.ts | 7 +-- .../components/prompt-input/store-types.ts | 1 - packages/app/src/i18n/en.ts | 29 +--------- packages/app/src/i18n/zh.ts | 28 +--------- 6 files changed, 12 insertions(+), 149 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 1ba2f618a..bfa9ed5ba 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1,5 +1,5 @@ import { useSpring } from "@opencode-ai/ui/motion-spring" -import { createEffect, on, Component, For, Show, onCleanup, createMemo, createSignal } from "solid-js" +import { createEffect, on, Component, For, Show, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { useFile } from "@/context/file" @@ -65,34 +65,6 @@ interface PromptInputProps { abortReady?: () => boolean } -const EXAMPLES = [ - "prompt.example.1", - "prompt.example.2", - "prompt.example.3", - "prompt.example.4", - "prompt.example.5", - "prompt.example.6", - "prompt.example.7", - "prompt.example.8", - "prompt.example.9", - "prompt.example.10", - "prompt.example.11", - "prompt.example.12", - "prompt.example.13", - "prompt.example.14", - "prompt.example.15", - "prompt.example.16", - "prompt.example.17", - "prompt.example.18", - "prompt.example.19", - "prompt.example.20", - "prompt.example.21", - "prompt.example.22", - "prompt.example.23", - "prompt.example.24", - "prompt.example.25", -] as const - export const PromptInput: Component = (props) => { const sdk = useSDK() const sync = useSync() @@ -153,7 +125,6 @@ export const PromptInput: Component = (props) => { popover: null, historyIndex: -1, savedPrompt: null, - placeholder: Math.floor(Math.random() * EXAMPLES.length), draggingType: null, mode: "normal", applyingHistory: false, @@ -206,14 +177,6 @@ export const PromptInput: Component = (props) => { return items.filter((item) => !item.comment?.trim()) }) - const hasUserPrompt = createMemo(() => { - const sessionID = activeSessionID() - if (!sessionID) return false - const messages = sync.data.message[sessionID] - if (!messages) return false - return messages.some((m) => m.role === "user") - }) - const { addToHistory, navigateHistory } = createHistoryNavigation({ store, setStore, @@ -223,8 +186,6 @@ export const PromptInput: Component = (props) => { queueScroll, }) - const suggest = createMemo(() => !hasUserPrompt()) - createEffect( on( () => store.mode, @@ -238,9 +199,7 @@ export const PromptInput: Component = (props) => { ? promptPlaceholder({ mode: store.mode, commentCount: commentCount(), - example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "", - suggest: suggest(), - t: (key, params) => language.t(key as Parameters[0], params as never), + t: (key) => language.t(key as Parameters[0]), }) : language.t("prompt.loading"), ) @@ -300,16 +259,6 @@ export const PromptInput: Component = (props) => { setStore("savedPrompt", null) } - createEffect(() => { - activeSessionID() - if (activeSessionID()) return - if (!suggest()) return - const interval = setInterval(() => { - setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) - }, 6500) - onCleanup(() => clearInterval(interval)) - }) - let popoversRef: PopoverControllers | null = null const popoversAccess = () => { if (!popoversRef) throw new Error("popoversRef accessed before initialization") diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts index 5f6aa59e9..f4feb9f34 100644 --- a/packages/app/src/components/prompt-input/placeholder.test.ts +++ b/packages/app/src/components/prompt-input/placeholder.test.ts @@ -2,47 +2,18 @@ import { describe, expect, test } from "bun:test" import { promptPlaceholder } from "./placeholder" describe("promptPlaceholder", () => { - const t = (key: string, params?: Record) => `${key}${params?.example ? `:${params.example}` : ""}` + const t = (key: string) => key test("returns shell placeholder in shell mode", () => { - const value = promptPlaceholder({ - mode: "shell", - commentCount: 0, - example: "example", - suggest: true, - t, - }) - expect(value).toBe("prompt.placeholder.shell") + expect(promptPlaceholder({ mode: "shell", commentCount: 0, t })).toBe("prompt.placeholder.shell") }) test("returns summarize placeholders for comment context", () => { - expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe( - "prompt.placeholder.summarizeComment", - ) - expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe( - "prompt.placeholder.summarizeComments", - ) + expect(promptPlaceholder({ mode: "normal", commentCount: 1, t })).toBe("prompt.placeholder.summarizeComment") + expect(promptPlaceholder({ mode: "normal", commentCount: 2, t })).toBe("prompt.placeholder.summarizeComments") }) - test("returns default placeholder with example when suggestions enabled", () => { - const value = promptPlaceholder({ - mode: "normal", - commentCount: 0, - example: "translated-example", - suggest: true, - t, - }) - expect(value).toBe("prompt.placeholder.normal:translated-example") - }) - - test("returns simple placeholder when suggestions disabled", () => { - const value = promptPlaceholder({ - mode: "normal", - commentCount: 0, - example: "translated-example", - suggest: false, - t, - }) - expect(value).toBe("prompt.placeholder.simple") + test("returns static home placeholder for normal mode with no comments", () => { + expect(promptPlaceholder({ mode: "normal", commentCount: 0, t })).toBe("prompt.placeholder.home") }) }) diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts index 395fee51b..53b8a3c65 100644 --- a/packages/app/src/components/prompt-input/placeholder.ts +++ b/packages/app/src/components/prompt-input/placeholder.ts @@ -1,15 +1,12 @@ type PromptPlaceholderInput = { mode: "normal" | "shell" commentCount: number - example: string - suggest: boolean - t: (key: string, params?: Record) => string + t: (key: string) => string } export function promptPlaceholder(input: PromptPlaceholderInput) { if (input.mode === "shell") return input.t("prompt.placeholder.shell") if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") - if (!input.suggest) return input.t("prompt.placeholder.simple") - return input.t("prompt.placeholder.normal", { example: input.example }) + return input.t("prompt.placeholder.home") } diff --git a/packages/app/src/components/prompt-input/store-types.ts b/packages/app/src/components/prompt-input/store-types.ts index 8ef6ec840..e1dc7ceac 100644 --- a/packages/app/src/components/prompt-input/store-types.ts +++ b/packages/app/src/components/prompt-input/store-types.ts @@ -4,7 +4,6 @@ export interface PromptStore { popover: "at" | "slash" | null historyIndex: number savedPrompt: PromptHistoryEntry | null - placeholder: number draggingType: "image" | "@mention" | null mode: "normal" | "shell" applyingHistory: boolean diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 9bf1ed771..a5043b52b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -258,8 +258,7 @@ export const dict = { "variant.max": "Max", "prompt.placeholder.shell": "Enter shell command...", - "prompt.placeholder.normal": 'Try... "{{example}}"', - "prompt.placeholder.simple": "Try...", + "prompt.placeholder.home": "Type your task, or @ to mention files", "prompt.placeholder.summarizeComments": "Summarize comments…", "prompt.placeholder.summarizeComment": "Summarize comment…", "prompt.mode.shell": "Shell", @@ -268,32 +267,6 @@ export const dict = { "session.child.promptDisabled": "Subagent sessions cannot be prompted.", "session.child.backToParent": "Back to main session.", - "prompt.example.1": "Draft a project update email", - "prompt.example.2": "Write a first-pass weekly update", - "prompt.example.3": "Reply to a customer inquiry email", - "prompt.example.4": "Draft a short product description", - "prompt.example.5": "Write a polite decline to a meeting request", - "prompt.example.6": "Summarize this Excel into a chart", - "prompt.example.7": "Turn invoice data into a monthly report", - "prompt.example.8": "Pull the key sales numbers from this spreadsheet", - "prompt.example.9": "Compare two quarters of sales data", - "prompt.example.10": "Clean up this CSV into a readable table", - "prompt.example.11": "Merge these PDFs with a table of contents", - "prompt.example.12": "Extract the key clauses from this contract", - "prompt.example.13": "Split this PDF into separate chapters", - "prompt.example.14": "Convert this Word document into PDF", - "prompt.example.15": "Extract the text from this PDF report", - "prompt.example.16": "Turn these meeting notes into minutes", - "prompt.example.17": "Extract action items from the meeting notes", - "prompt.example.18": "Turn these interview notes into an insights list", - "prompt.example.19": "Break this memo into a task list", - "prompt.example.20": "Rewrite this passage more concisely", - "prompt.example.21": "Summarize this long document in a paragraph", - "prompt.example.22": "Translate this Chinese memo into English", - "prompt.example.23": "Extract the key takeaways from this article", - "prompt.example.24": "Summarize this long email in a few sentences", - "prompt.example.25": "Rewrite this technical document in plain English", - "prompt.popover.emptyResults": "No matching results", "prompt.popover.emptyCommands": "No matching commands", "prompt.dropzone.label": "Drop images, PDFs, or text files here", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index b727bb318..42af4f5f0 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -276,8 +276,7 @@ export const dict = { "variant.max": "最高", "prompt.placeholder.shell": "输入 shell 命令...", - "prompt.placeholder.normal": '试试... "{{example}}"', - "prompt.placeholder.simple": "试试...", + "prompt.placeholder.home": "输入你的任务,或 @ 引用文件", "prompt.placeholder.summarizeComments": "总结评论…", "prompt.placeholder.summarizeComment": "总结该评论…", "prompt.mode.shell": "Shell", @@ -285,31 +284,6 @@ export const dict = { "prompt.mode.shell.exit": "按 esc 退出", "session.child.promptDisabled": "子任务运行中,暂时无法输入。", "session.child.backToParent": "返回主会话", - "prompt.example.1": "起草一封项目进度邮件", - "prompt.example.2": "写一份周报初稿给领导", - "prompt.example.3": "回复这封客户咨询邮件", - "prompt.example.4": "帮我写一段产品介绍文案", - "prompt.example.5": "这封邮件怎么写才更礼貌?", - "prompt.example.6": "把这份 Excel 汇总成图表", - "prompt.example.7": "整理这份发票数据做成月报", - "prompt.example.8": "从表格里提取销售要点", - "prompt.example.9": "这份表格里哪个产品销售最好?", - "prompt.example.10": "把 CSV 整理成易读的表格", - "prompt.example.11": "合并几份 PDF 并生成目录", - "prompt.example.12": "从合同里提取关键条款", - "prompt.example.13": "把 PDF 拆成独立章节", - "prompt.example.14": "把 Word 文档转成 PDF", - "prompt.example.15": "这份 PDF 讲了什么?", - "prompt.example.16": "把这段会议记录整理成纪要", - "prompt.example.17": "从会议笔记里提取行动项", - "prompt.example.18": "帮我把这段客户访谈整理成洞察清单", - "prompt.example.19": "把这份备忘拆成任务清单", - "prompt.example.20": "这段文字怎么改更简练?", - "prompt.example.21": "给这篇长文写一段摘要", - "prompt.example.22": "把这份英文报告翻译成中文", - "prompt.example.23": "这篇文章的关键观点是什么?", - "prompt.example.24": "把这封长邮件总结成几句话", - "prompt.example.25": "把这份技术文档改写成通俗版", "prompt.popover.emptyResults": "没有匹配的结果", "prompt.popover.emptyCommands": "没有匹配的命令", "prompt.dropzone.label": "将图片、PDF 或文本文件拖放到此处", From 54dc6591f5a7be2a8f6a3b4cf7104eea10b1d405 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 15:17:07 +0800 Subject: [PATCH 02/30] feat(app): add home suggestion settings keys and toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit introduce general.homeSuggestionsEnabled (default true) and general.homeSuggestionsDismissed (default empty array) on the persisted settings.v3 store, with withFallback accessors for migration safety. settings → general gains a toggle row with an inline restore button visible only when all three rows are dismissed. include a contract test for the row wiring so the localStorage-based E2E does not silently mask broken UI bindings. Part of the first-time-visitor onboarding redesign. --- .../settings-general.home-suggestions.test.ts | 24 ++++++++++++++ .../app/src/components/settings-general.tsx | 31 +++++++++++++++++++ packages/app/src/context/settings.tsx | 18 +++++++++++ packages/app/src/i18n/en.ts | 3 ++ packages/app/src/i18n/zh.ts | 3 ++ 5 files changed, 79 insertions(+) create mode 100644 packages/app/src/components/settings-general.home-suggestions.test.ts diff --git a/packages/app/src/components/settings-general.home-suggestions.test.ts b/packages/app/src/components/settings-general.home-suggestions.test.ts new file mode 100644 index 000000000..596ba1af4 --- /dev/null +++ b/packages/app/src/components/settings-general.home-suggestions.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" + +describe("settings-general home suggestions row", () => { + const source = readFileSync("src/components/settings-general.tsx", "utf8") + + test("renders the suggestion toggle row with the documented i18n title", () => { + expect(source).toContain("settings.general.homeSuggestions") + }) + + test("wires the Switch to settings.general.homeSuggestionsEnabled accessor", () => { + expect(source).toContain("settings.general.homeSuggestionsEnabled()") + expect(source).toContain("settings.general.setHomeSuggestionsEnabled(") + }) + + test("exposes a restore-all button gated by all-three-dismissed state", () => { + expect(source).toContain("settings.general.homeSuggestionsDismissed().length >= 3") + expect(source).toContain("settings.general.setHomeSuggestionsDismissed([])") + }) + + test("clears dismissed list when re-enabling and previously all-dismissed", () => { + expect(source).toMatch(/setHomeSuggestionsEnabled\(checked\)[\s\S]{0,400}setHomeSuggestionsDismissed\(\[\]\)/) + }) +}) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index c1e47ad20..b04cc573c 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -120,6 +120,37 @@ export const SettingsGeneral: Component = () => { + +
+ = 3 + } + > + + + { + settings.general.setHomeSuggestionsEnabled(checked) + if (checked && settings.general.homeSuggestionsDismissed().length >= 3) { + settings.general.setHomeSuggestionsDismissed([]) + } + }} + /> +
+
+ store.general?.homeSuggestionsEnabled, + defaultSettings.general.homeSuggestionsEnabled, + ), + setHomeSuggestionsEnabled(value: boolean) { + setStore("general", "homeSuggestionsEnabled", value) + }, + homeSuggestionsDismissed: withFallback( + () => store.general?.homeSuggestionsDismissed, + defaultSettings.general.homeSuggestionsDismissed, + ), + setHomeSuggestionsDismissed(value: string[]) { + setStore("general", "homeSuggestionsDismissed", value) + }, }, updates: { startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index a5043b52b..ce6090a59 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -878,6 +878,9 @@ export const dict = { "Show edit, write, and patch tool parts expanded by default in the timeline", "settings.general.row.lsp.title": "Language Server Protocol (LSP)", "settings.general.row.lsp.description": "Detect type errors and symbol references when editing code", + "settings.general.homeSuggestions": "Home prompt suggestions", + "settings.general.homeSuggestions.description": "Show 3 prompt suggestion rows under the composer for new visitors. Click a row to prefill the input.", + "settings.general.homeSuggestions.reset": "Restore all suggestions", "settings.general.webSearch.title": "Web search", "settings.general.webSearch.description": "Let agents look up fresh information online when needed", "settings.general.webSearch.chip.free": "Free (bundled)", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 42af4f5f0..c583201d5 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -767,6 +767,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分", "settings.general.row.lsp.title": "语言服务器协议(LSP)", "settings.general.row.lsp.description": "修改代码时识别项目类型错误和符号引用", + "settings.general.homeSuggestions": "首页提示词建议", + "settings.general.homeSuggestions.description": "新用户在首页 composer 下方显示 3 行提示词建议;点击预填到输入框", + "settings.general.homeSuggestions.reset": "恢复全部建议", "settings.general.webSearch.title": "网页搜索", "settings.general.webSearch.description": "联网获取最新资料", "settings.general.webSearch.chip.free": "内置免费额度", From c75c16caadc7815545dee3d5c73a8aa133129fce Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 15:17:59 +0800 Subject: [PATCH 03/30] feat(app): add pure helper resolving visible home suggestion chips introduces HOME_SUGGESTION_CHIPS (3 stable chip ids) and resolveVisibleHomeSuggestions() which returns the visible id list given firstTimeVisitor / enabled / dismissed state. pure function so it can be unit-tested without dom or solid runtime. --- .../home/home-suggestions-state.test.ts | 46 +++++++++++++++++++ .../components/home/home-suggestions-state.ts | 25 ++++++++++ 2 files changed, 71 insertions(+) create mode 100644 packages/app/src/components/home/home-suggestions-state.test.ts create mode 100644 packages/app/src/components/home/home-suggestions-state.ts diff --git a/packages/app/src/components/home/home-suggestions-state.test.ts b/packages/app/src/components/home/home-suggestions-state.test.ts new file mode 100644 index 000000000..ed4e4b95f --- /dev/null +++ b/packages/app/src/components/home/home-suggestions-state.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { + HOME_SUGGESTION_CHIPS, + resolveVisibleHomeSuggestions, + type HomeSuggestionChipID, +} from "./home-suggestions-state" + +describe("resolveVisibleHomeSuggestions", () => { + const allIDs = HOME_SUGGESTION_CHIPS.map((chip) => chip.id) + + test("returns all chips when first-time + enabled + nothing dismissed", () => { + expect(resolveVisibleHomeSuggestions({ firstTimeVisitor: true, enabled: true, dismissed: [] })).toEqual(allIDs) + }) + + test("returns empty when not a first-time visitor", () => { + expect(resolveVisibleHomeSuggestions({ firstTimeVisitor: false, enabled: true, dismissed: [] })).toEqual([]) + }) + + test("returns empty when feature is disabled", () => { + expect(resolveVisibleHomeSuggestions({ firstTimeVisitor: true, enabled: false, dismissed: [] })).toEqual([]) + }) + + test("filters dismissed chips while preserving original order", () => { + const dismissed: HomeSuggestionChipID[] = ["news-brief"] + expect( + resolveVisibleHomeSuggestions({ firstTimeVisitor: true, enabled: true, dismissed }), + ).toEqual(allIDs.filter((id) => id !== "news-brief")) + }) + + test("returns empty when all three chips are dismissed", () => { + expect( + resolveVisibleHomeSuggestions({ firstTimeVisitor: true, enabled: true, dismissed: allIDs }), + ).toEqual([]) + }) + + test("HOME_SUGGESTION_CHIPS has stable IDs and three entries", () => { + expect(HOME_SUGGESTION_CHIPS).toHaveLength(3) + expect(allIDs).toEqual(["analyze-spreadsheet", "news-brief", "draft-email"]) + }) + + test("each chip exposes a stable i18n key", () => { + for (const chip of HOME_SUGGESTION_CHIPS) { + expect(chip.i18nKey).toMatch(/^home\.suggestion\./) + } + }) +}) diff --git a/packages/app/src/components/home/home-suggestions-state.ts b/packages/app/src/components/home/home-suggestions-state.ts new file mode 100644 index 000000000..12d7f1627 --- /dev/null +++ b/packages/app/src/components/home/home-suggestions-state.ts @@ -0,0 +1,25 @@ +export type HomeSuggestionChipID = "analyze-spreadsheet" | "news-brief" | "draft-email" + +export interface HomeSuggestionChip { + id: HomeSuggestionChipID + i18nKey: string +} + +export const HOME_SUGGESTION_CHIPS: readonly HomeSuggestionChip[] = [ + { id: "analyze-spreadsheet", i18nKey: "home.suggestion.analyze-spreadsheet" }, + { id: "news-brief", i18nKey: "home.suggestion.news-brief" }, + { id: "draft-email", i18nKey: "home.suggestion.draft-email" }, +] + +export interface ResolveHomeSuggestionsInput { + firstTimeVisitor: boolean + enabled: boolean + dismissed: readonly HomeSuggestionChipID[] +} + +export function resolveVisibleHomeSuggestions(input: ResolveHomeSuggestionsInput): HomeSuggestionChipID[] { + if (!input.firstTimeVisitor) return [] + if (!input.enabled) return [] + const dismissedSet = new Set(input.dismissed) + return HOME_SUGGESTION_CHIPS.map((chip) => chip.id).filter((id) => !dismissedSet.has(id)) +} From 678dcf5eec233b5b66fd7478896dcd094fc2d03c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 15:19:47 +0800 Subject: [PATCH 04/30] feat(app): add home suggestion list component renders three prompt suggestion rows under the home composer for first-time visitors. clicking a row prefills the composer via usePrompt().set and focuses the editor; per-row X writes the chip id into settings.general.homeSuggestionsDismissed; section X dismisses all three at once. component returns null when not a first-time visitor, when feature is disabled, or when all chips are dismissed. sync.ready gate avoids flashing during initial sync hydration. --- .../home/home-suggestion-list.test.ts | 58 +++++++++ .../components/home/home-suggestion-list.tsx | 115 ++++++++++++++++++ packages/app/src/i18n/en.ts | 6 + packages/app/src/i18n/zh.ts | 6 + 4 files changed, 185 insertions(+) create mode 100644 packages/app/src/components/home/home-suggestion-list.test.ts create mode 100644 packages/app/src/components/home/home-suggestion-list.tsx diff --git a/packages/app/src/components/home/home-suggestion-list.test.ts b/packages/app/src/components/home/home-suggestion-list.test.ts new file mode 100644 index 000000000..611132836 --- /dev/null +++ b/packages/app/src/components/home/home-suggestion-list.test.ts @@ -0,0 +1,58 @@ +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("computes firstTimeVisitor from sync.data.session count", () => { + expect(source).toContain("sync.data.session") + expect(source).toMatch(/Object\.keys\([^)]*session[^)]*\)\.length/) + }) + + test("guards firstTimeVisitor on sync.ready to avoid flashing during hydration", () => { + expect(source).toContain("sync.ready") + }) + + 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"') + expect(source).toContain('data-action="home-suggestion-section-dismiss"') + }) + + test("prefills the composer via prompt.set and focuses the editor", () => { + expect(source).toContain("prompt.set([") + expect(source).toContain('[data-component="prompt-input"]') + }) + + test("uses the settings accessor for read and write (not raw store)", () => { + expect(source).toContain("settings.general.homeSuggestionsEnabled()") + expect(source).toContain("settings.general.homeSuggestionsDismissed()") + expect(source).toContain("settings.general.setHomeSuggestionsDismissed(") + // negative: must NOT reach into the raw store or call setStore directly + expect(source).not.toContain("settings.store.general") + expect(source).not.toContain("settings.setStore(") + }) + + test("section dismiss writes all three chip ids", () => { + expect(source).toContain("HOME_SUGGESTION_CHIPS.map") + }) + + test("renders nothing when there are no visible chips", () => { + expect(source).toContain("visibleChips().length > 0") + }) + + test("uses i18n keys for chip text and aria-labels", () => { + expect(source).toContain("home.suggestion.section.label") + expect(source).toContain("home.suggestion.section.dismiss") + expect(source).toContain("home.suggestion.row.dismiss") + }) +}) diff --git a/packages/app/src/components/home/home-suggestion-list.tsx b/packages/app/src/components/home/home-suggestion-list.tsx new file mode 100644 index 000000000..86330eb42 --- /dev/null +++ b/packages/app/src/components/home/home-suggestion-list.tsx @@ -0,0 +1,115 @@ +import { For, Show, createMemo, type Component } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { useLanguage } from "@/context/language" +import { usePrompt } from "@/context/prompt" +import { useSettings } from "@/context/settings" +import { useSync } from "@/context/sync" +import { + HOME_SUGGESTION_CHIPS, + resolveVisibleHomeSuggestions, + type HomeSuggestionChipID, +} from "./home-suggestions-state" + +const PROMPT_EDITOR_SELECTOR = '[data-component="prompt-input"]' + +function focusComposerEditor() { + if (typeof document === "undefined") return + const editor = document.querySelector(PROMPT_EDITOR_SELECTOR) + editor?.focus() +} + +export const HomeSuggestionList: Component = () => { + const language = useLanguage() + const prompt = usePrompt() + const settings = useSettings() + const sync = useSync() + + const sessionCount = createMemo(() => Object.keys(sync.data.session ?? {}).length) + // sync.ready guards against showing chips during the initial loading hydration — + // sync.data.session is an empty object while status === "loading", which would + // make every user (returning or new) momentarily look like a first-time visitor. + // sync.ready is a getter on the context (not a function). + const firstTimeVisitor = createMemo(() => sync.ready && sessionCount() === 0) + + const visibleIDs = createMemo(() => + resolveVisibleHomeSuggestions({ + firstTimeVisitor: firstTimeVisitor(), + enabled: settings.general.homeSuggestionsEnabled(), + dismissed: settings.general.homeSuggestionsDismissed() as HomeSuggestionChipID[], + }), + ) + + const visibleChips = createMemo(() => { + const ids = new Set(visibleIDs()) + return HOME_SUGGESTION_CHIPS.filter((chip) => ids.has(chip.id)) + }) + + type I18nKey = Parameters[0] + + const prefill = (text: string) => { + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + requestAnimationFrame(focusComposerEditor) + } + + const dismissRow = (id: HomeSuggestionChipID) => { + const current = settings.general.homeSuggestionsDismissed() as HomeSuggestionChipID[] + if (current.includes(id)) return + settings.general.setHomeSuggestionsDismissed([...current, id]) + } + + const dismissAll = () => { + settings.general.setHomeSuggestionsDismissed(HOME_SUGGESTION_CHIPS.map((chip) => chip.id)) + } + + return ( + 0}> +
+
+
+ {language.t("home.suggestion.section.label")} +
+
+ +
+
    + + {(chip) => ( +
  • + + +
  • + )} +
    +
+
+
+ ) +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index ce6090a59..20e4117e0 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -570,6 +570,12 @@ export const dict = { "home.recentProjects": "Recent projects", "home.hero.title": "What should we work on?", + "home.suggestion.section.label": "Prompt suggestions", + "home.suggestion.section.dismiss": "Dismiss all prompt suggestions", + "home.suggestion.row.dismiss": "Dismiss this prompt", + "home.suggestion.analyze-spreadsheet": "Analyze a spreadsheet — surface key data and anomalies", + "home.suggestion.news-brief": "Summarize today's news worth following into a brief", + "home.suggestion.draft-email": "Draft a work email for me", "session.tab.session": "Session", "session.tab.review": "Review", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index c583201d5..0db7fd4a0 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -540,6 +540,12 @@ export const dict = { "home.recentProjects": "最近项目", "home.hero.title": "今天我们做点什么?", + "home.suggestion.section.label": "提示词建议", + "home.suggestion.section.dismiss": "关闭所有提示词建议", + "home.suggestion.row.dismiss": "关闭该提示词", + "home.suggestion.analyze-spreadsheet": "帮我分析一份表格,找出关键数据和异常", + "home.suggestion.news-brief": "查一下今天值得关注的新闻,整理一份简报", + "home.suggestion.draft-email": "帮我起草一封工作邮件", "session.tab.session": "会话", "session.tab.review": "审查", From 82b6ba48a5f74bcf15c15805c4f6b27218569774 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 15:20:40 +0800 Subject: [PATCH 05/30] feat(app): render HomeSuggestionList under the home composer wire the new suggestion list as a sibling of the composer container in NewSessionView. visibility, dismissal, and prefill behavior are owned by the component itself; NewSessionView only handles layout placement. --- packages/app/src/components/session/session-new-view.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index c0eac4636..f70656fcb 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -1,5 +1,6 @@ import { Show, type JSX } from "solid-js" import { useLanguage } from "@/context/language" +import { HomeSuggestionList } from "@/components/home/home-suggestion-list" type ComposerCtx = { onModeChange: (mode: "normal" | "shell") => void @@ -23,6 +24,7 @@ export function NewSessionView(props: { composer?: (ctx: ComposerCtx) => JSX.Ele
{props.composer!({ onModeChange: () => {} })}
+ From 00934d3716f139138a7fad33ea217ecab1d2d26f Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 15:22:04 +0800 Subject: [PATCH 06/30] test(app): e2e for home suggestion chips onboarding cover 10 paths from the spec: first-time visitor renders 3 rows, clicking prefills + focuses the editor, per-row X persists across reload, section X hides the whole section, placeholder is static (no rotation), returning visitors see no suggestion list, hover reveals per-row X, editing prefilled text preserves the edit on send, settings toggle hides and restores the section, and language switch preserves dismissed state. update the @smoke inventory for the new @smoke titles. --- .../onboarding/home-suggestion-chips.spec.ts | 171 ++++++++++++++++++ .../test/config/e2e-smoke-tagging.test.ts | 5 + 2 files changed, 176 insertions(+) create mode 100644 packages/app/e2e/onboarding/home-suggestion-chips.spec.ts diff --git a/packages/app/e2e/onboarding/home-suggestion-chips.spec.ts b/packages/app/e2e/onboarding/home-suggestion-chips.spec.ts new file mode 100644 index 000000000..607b07830 --- /dev/null +++ b/packages/app/e2e/onboarding/home-suggestion-chips.spec.ts @@ -0,0 +1,171 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" + +const suggestionListSelector = '[data-component="home-suggestion-list"]' +const rowSelector = '[data-action="home-suggestion-row"]' +const rowDismissSelector = '[data-action="home-suggestion-row-dismiss"]' +const sectionDismissSelector = '[data-action="home-suggestion-section-dismiss"]' + +test("@smoke home shows 3 suggestion rows for a first-time visitor", async ({ page, project }) => { + await project.open() + + const list = page.locator(suggestionListSelector) + await expect(list).toBeVisible() + await expect(list.locator(rowSelector)).toHaveCount(3) +}) + +test("@smoke clicking a suggestion row prefills the composer", async ({ page, project }) => { + await project.open() + + const list = page.locator(suggestionListSelector) + const firstRow = list.locator(rowSelector).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 / wrapping + // spans, so toHaveText (strict equality) can be flaky. toContainText asserts substring after + // textContent normalization which is the right granularity 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(suggestionListSelector) + await expect(list.locator(rowSelector)).toHaveCount(3) + + const firstRow = list.locator(rowSelector).first() + await firstRow.hover() + await list.locator(rowDismissSelector).first().click() + + await expect(list.locator(rowSelector)).toHaveCount(2) + + await page.reload() + await expect(page.locator(suggestionListSelector).locator(rowSelector)).toHaveCount(2) +}) + +test("@smoke section X dismisses all three rows and hides the section", async ({ page, project }) => { + await project.open() + + const list = page.locator(suggestionListSelector) + await expect(list.locator(rowSelector)).toHaveCount(3) + await list.locator(sectionDismissSelector).click() + await expect(page.locator(suggestionListSelector)).toHaveCount(0) +}) + +test("@smoke composer placeholder is the static home string and does not rotate", async ({ page, project }) => { + await project.open() + + const editor = page.locator(promptSelector) + await expect(editor).toBeVisible() + const initialLabel = await editor.getAttribute("aria-label") + expect(initialLabel).toMatch(/(输入你的任务|Type your task)/) + + // wait ~7s (longer than the deleted 6.5s rotation) and confirm the label is unchanged + await page.waitForTimeout(7000) + const labelAfter = await editor.getAttribute("aria-label") + expect(labelAfter).toBe(initialLabel) +}) + +test("returning visitor (sessions > 0) sees no suggestion list", async ({ page, project, assistant }) => { + await project.open() + + // create a session by sending a prompt + await assistant.reply("reply to trigger a session") + await page.locator(promptSelector).click() + await page.keyboard.type("first prompt") + await page.keyboard.press("Enter") + await expect.poll(() => page.url(), { timeout: 30_000 }).toContain("/session/") + + // navigate back to home — sessions now == 1 + await project.open() + await expect(page.locator(suggestionListSelector)).toHaveCount(0) +}) + +test("hover reveals the per-row X (rest state hides it)", async ({ page, project }) => { + await project.open() + + // disable CSS transitions to remove timing flakes between rest / hover state + await page.addStyleTag({ content: "* { transition: none !important; animation: none !important; }" }) + + const list = page.locator(suggestionListSelector) + const firstRow = list.locator(rowSelector).first() + const firstRowDismiss = list.locator(rowDismissSelector).first() + + // rest state: per-row X is rendered but not visible (opacity-0) + await expect(firstRowDismiss).toHaveCSS("opacity", "0") + + // hover the row → group-hover reveals the X (opacity-1) + await firstRow.hover() + await expect(firstRowDismiss).toHaveCSS("opacity", "1") +}) + +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(suggestionListSelector).locator(rowSelector).first().click() + await expect(editor).toBeFocused() + + // append a suffix + await page.keyboard.type(" — please be concise") + await page.keyboard.press("Enter") + + await expect.poll(() => page.url(), { timeout: 30_000 }).toContain("/session/") + // the user message rendered should contain both the prefilled text and the suffix + await expect(page.getByText(/please be concise/)).toBeVisible() +}) + +test("settings toggle hides and restores the suggestion section", async ({ page, project }) => { + await project.open() + await expect(page.locator(suggestionListSelector)).toBeVisible() + + // toggle off via localStorage write (Settings page navigation/click is covered by + // the contract test for settings-general.tsx; this E2E focuses on persisted-state + // behaviour — does the suggestion section actually disappear when the flag flips) + await page.evaluate(() => { + // settings.v3 lives in localStorage as the persisted store; flip the key directly + // to avoid full-flow navigation in this test. + const raw = localStorage.getItem("settings.v3") + const parsed = raw ? JSON.parse(raw) : {} + parsed.general = { ...(parsed.general ?? {}), homeSuggestionsEnabled: false } + localStorage.setItem("settings.v3", JSON.stringify(parsed)) + }) + await page.reload() + await expect(page.locator(suggestionListSelector)).toHaveCount(0) + + await page.evaluate(() => { + const raw = localStorage.getItem("settings.v3") + const parsed = raw ? JSON.parse(raw) : {} + parsed.general = { ...(parsed.general ?? {}), homeSuggestionsEnabled: true } + localStorage.setItem("settings.v3", JSON.stringify(parsed)) + }) + await page.reload() + await expect(page.locator(suggestionListSelector).locator(rowSelector)).toHaveCount(3) +}) + +test("language switch updates chip text without losing dismissed state", async ({ page, project }) => { + await project.open() + + const list = page.locator(suggestionListSelector) + // dismiss the first row in current locale + await list.locator(rowSelector).first().hover() + await list.locator(rowDismissSelector).first().click() + await expect(list.locator(rowSelector)).toHaveCount(2) + + // Confirmed via packages/app/src/context/language.tsx — persistence key is "language", + // shape is { locale: "zh" | "en" }, migrated from legacy "language.v1". + await page.evaluate(() => { + const raw = localStorage.getItem("language") + const parsed = raw ? JSON.parse(raw) : { locale: "zh" } + parsed.locale = parsed.locale?.startsWith?.("zh") ? "en" : "zh" + localStorage.setItem("language", JSON.stringify(parsed)) + }) + await page.reload() + + // still 2 rows visible (dismissed state preserved across locale flip) + await expect(page.locator(suggestionListSelector).locator(rowSelector)).toHaveCount(2) +}) diff --git a/packages/opencode/test/config/e2e-smoke-tagging.test.ts b/packages/opencode/test/config/e2e-smoke-tagging.test.ts index 68a7e114a..4fb77d395 100644 --- a/packages/opencode/test/config/e2e-smoke-tagging.test.ts +++ b/packages/opencode/test/config/e2e-smoke-tagging.test.ts @@ -14,6 +14,11 @@ const expectedSmokeTests = [ "packages/app/e2e/app/session.spec.ts:@smoke session composer matches home structure without docktray or agent control", "packages/app/e2e/app/shell-frame.spec.ts:@smoke shell frame exposes stable desktop hooks", "packages/app/e2e/files/file-tree.spec.ts:@smoke review tab no longer renders the legacy file-tree sub-panel", + "packages/app/e2e/onboarding/home-suggestion-chips.spec.ts:@smoke clicking a suggestion row prefills the composer", + "packages/app/e2e/onboarding/home-suggestion-chips.spec.ts:@smoke composer placeholder is the static home string and does not rotate", + "packages/app/e2e/onboarding/home-suggestion-chips.spec.ts:@smoke home shows 3 suggestion rows for a first-time visitor", + "packages/app/e2e/onboarding/home-suggestion-chips.spec.ts:@smoke per-row X dismisses one row and persists across reload", + "packages/app/e2e/onboarding/home-suggestion-chips.spec.ts:@smoke section X dismisses all three rows and hides the section", "packages/app/e2e/prompt/first-message-reply.spec.ts:@smoke first replied message in a new session renders without page errors", "packages/app/e2e/prompt/prompt.spec.ts:@smoke can send a prompt and receive a reply", "packages/app/e2e/release-notes/release-notes-toast.spec.ts:@smoke shows subtle toast when stored version is older than current", From 9366f4dc1e151bdf125798a8d0d0684f13589cb7 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 15:38:06 +0800 Subject: [PATCH 07/30] fix(app): harden home suggestion chips per code crosscheck Round 1 fixes after multi-model code review: - Persist a one-way `homeSuggestionsSeen` flag so returning users who delete every session do not re-enter onboarding when they revisit home. - Replace bare `as HomeSuggestionChipID[]` cast with a known-id filter so stale persisted IDs from a renamed chip do not poison the visibility contract or block the settings restore action. - Honor `prompt.dirty()` when a chip click would otherwise overwrite the user-typed message, appending the suggestion with a separator instead. - Explicitly `setCursorPosition(editor, text.length)` after `focus()` so follow-up typing is not browser-selection dependent. - Switch the rest-state row dismiss button to `pointer-events-none` and `tabIndex={-1}` so the invisible control cannot receive accidental clicks or stops in tab order, then restore both on group hover. - Settings page: gate the restore button on any chip dismissed (not all three) and replace the hard-coded `>= 3` with `HOME_SUGGESTION_CHIPS.length` so the section count is not brittle. - Tighten the E2E placeholder check to a single attribute snapshot against the actual i18n string and drop the 7s sleep; verify chip text re-renders on locale switch instead of just row count; exercise the real Settings switch flow rather than a localStorage poke. --- .../onboarding/home-suggestion-chips.spec.ts | 72 ++++++++++--------- .../home/home-suggestion-list.test.ts | 29 +++++++- .../components/home/home-suggestion-list.tsx | 69 ++++++++++++++---- .../settings-general.home-suggestions.test.ts | 9 ++- .../app/src/components/settings-general.tsx | 8 ++- packages/app/src/context/settings.tsx | 14 ++++ .../test/config/e2e-smoke-tagging.test.ts | 2 +- 7 files changed, 153 insertions(+), 50 deletions(-) diff --git a/packages/app/e2e/onboarding/home-suggestion-chips.spec.ts b/packages/app/e2e/onboarding/home-suggestion-chips.spec.ts index 607b07830..eea795908 100644 --- a/packages/app/e2e/onboarding/home-suggestion-chips.spec.ts +++ b/packages/app/e2e/onboarding/home-suggestion-chips.spec.ts @@ -55,18 +55,16 @@ test("@smoke section X dismisses all three rows and hides the section", async ({ await expect(page.locator(suggestionListSelector)).toHaveCount(0) }) -test("@smoke composer placeholder is the static home string and does not rotate", async ({ page, project }) => { +test("@smoke composer placeholder is the static home string", async ({ page, project }) => { await project.open() const editor = page.locator(promptSelector) await expect(editor).toBeVisible() - const initialLabel = await editor.getAttribute("aria-label") - expect(initialLabel).toMatch(/(输入你的任务|Type your task)/) - - // wait ~7s (longer than the deleted 6.5s rotation) and confirm the label is unchanged - await page.waitForTimeout(7000) - const labelAfter = await editor.getAttribute("aria-label") - expect(labelAfter).toBe(initialLabel) + // Match the single static home placeholder in either locale. The legacy 25-prompt + // rotation pool and its 6.5s interval were removed. The resource file is the only + // source of truth. See packages/app/src/components/prompt-input/placeholder.ts. + const label = await editor.getAttribute("aria-label") + expect(label).toMatch(/(输入你的任务,或 @ 引用文件|Type your task, or @ to mention files)/) }) test("returning visitor (sessions > 0) sees no suggestion list", async ({ page, project, assistant }) => { @@ -96,10 +94,13 @@ test("hover reveals the per-row X (rest state hides it)", async ({ page, project // rest state: per-row X is rendered but not visible (opacity-0) await expect(firstRowDismiss).toHaveCSS("opacity", "0") + // and not clickable (pointer-events-none keeps it out of the hit-test surface) + await expect(firstRowDismiss).toHaveCSS("pointer-events", "none") - // hover the row → group-hover reveals the X (opacity-1) + // hover the row → group-hover reveals the X (opacity-1) and re-enables clicks await firstRow.hover() await expect(firstRowDismiss).toHaveCSS("opacity", "1") + await expect(firstRowDismiss).toHaveCSS("pointer-events", "auto") }) test("editing a prefilled suggestion preserves the user's edit on send", async ({ page, project, assistant }) => { @@ -111,7 +112,7 @@ test("editing a prefilled suggestion preserves the user's edit on send", async ( await expect(editor).toBeFocused() // append a suffix - await page.keyboard.type(" — please be concise") + await page.keyboard.type(" please be concise") await page.keyboard.press("Enter") await expect.poll(() => page.url(), { timeout: 30_000 }).toContain("/session/") @@ -119,31 +120,29 @@ test("editing a prefilled suggestion preserves the user's edit on send", async ( await expect(page.getByText(/please be concise/)).toBeVisible() }) -test("settings toggle hides and restores the suggestion section", async ({ page, project }) => { +test("settings toggle hides and restores the suggestion section via the real Settings UI", async ({ + page, + project, +}) => { await project.open() await expect(page.locator(suggestionListSelector)).toBeVisible() - // toggle off via localStorage write (Settings page navigation/click is covered by - // the contract test for settings-general.tsx; this E2E focuses on persisted-state - // behaviour — does the suggestion section actually disappear when the flag flips) - await page.evaluate(() => { - // settings.v3 lives in localStorage as the persisted store; flip the key directly - // to avoid full-flow navigation in this test. - const raw = localStorage.getItem("settings.v3") - const parsed = raw ? JSON.parse(raw) : {} - parsed.general = { ...(parsed.general ?? {}), homeSuggestionsEnabled: false } - localStorage.setItem("settings.v3", JSON.stringify(parsed)) - }) - await page.reload() + // Open Settings (kbd shortcut path is project-wide; settings page is a full pane, + // not a dialog — see settings.spec.ts for the canonical opening behavior). + await page.goto("#/settings/general") + const switchEl = page.locator('[data-action="settings-home-suggestions"] [role="switch"]') + await expect(switchEl).toBeVisible() + // turn off + await switchEl.click() + + await project.open() await expect(page.locator(suggestionListSelector)).toHaveCount(0) - await page.evaluate(() => { - const raw = localStorage.getItem("settings.v3") - const parsed = raw ? JSON.parse(raw) : {} - parsed.general = { ...(parsed.general ?? {}), homeSuggestionsEnabled: true } - localStorage.setItem("settings.v3", JSON.stringify(parsed)) - }) - await page.reload() + // turn it back on via the real Settings switch + await page.goto("#/settings/general") + await switchEl.click() + + await project.open() await expect(page.locator(suggestionListSelector).locator(rowSelector)).toHaveCount(3) }) @@ -151,12 +150,15 @@ test("language switch updates chip text without losing dismissed state", async ( await project.open() const list = page.locator(suggestionListSelector) + // capture text of the second row in the current locale (first row will be dismissed below) + const secondRowTextBefore = (await list.locator(rowSelector).nth(1).innerText()).trim() + // dismiss the first row in current locale await list.locator(rowSelector).first().hover() await list.locator(rowDismissSelector).first().click() await expect(list.locator(rowSelector)).toHaveCount(2) - // Confirmed via packages/app/src/context/language.tsx — persistence key is "language", + // Confirmed via packages/app/src/context/language.tsx. Persistence key is "language", // shape is { locale: "zh" | "en" }, migrated from legacy "language.v1". await page.evaluate(() => { const raw = localStorage.getItem("language") @@ -167,5 +169,11 @@ test("language switch updates chip text without losing dismissed state", async ( await page.reload() // still 2 rows visible (dismissed state preserved across locale flip) - await expect(page.locator(suggestionListSelector).locator(rowSelector)).toHaveCount(2) + const listAfter = page.locator(suggestionListSelector) + await expect(listAfter.locator(rowSelector)).toHaveCount(2) + + // and chip text actually flipped to the other locale (key insight: the row text + // must NOT equal the pre-flip text — that would mean i18n didn't propagate) + const secondRowTextAfter = (await listAfter.locator(rowSelector).nth(0).innerText()).trim() + expect(secondRowTextAfter).not.toBe(secondRowTextBefore) }) diff --git a/packages/app/src/components/home/home-suggestion-list.test.ts b/packages/app/src/components/home/home-suggestion-list.test.ts index 611132836..9aa68f40e 100644 --- a/packages/app/src/components/home/home-suggestion-list.test.ts +++ b/packages/app/src/components/home/home-suggestion-list.test.ts @@ -21,6 +21,14 @@ describe("HomeSuggestionList source contract", () => { expect(source).toContain("sync.ready") }) + test("uses homeSuggestionsSeen as a one-way bit so returning users do not re-enter onboarding", () => { + // firstTimeVisitor must factor in seen flag + expect(source).toContain("homeSuggestionsSeen") + expect(source).toContain("setHomeSuggestionsSeen(true)") + // session-count > 0 should flip seen=true via createEffect (one-way hydration latch) + expect(source).toMatch(/createEffect\([\s\S]{0,400}setHomeSuggestionsSeen\(true\)/) + }) + 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"') @@ -33,11 +41,24 @@ describe("HomeSuggestionList source contract", () => { 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("respects user-typed content via prompt.dirty() before overwriting", () => { + expect(source).toContain("prompt.dirty()") + }) + + test("filters dismissed IDs against known chip IDs (no bare type cast)", () => { + expect(source).toContain("filterKnownIDs") + // negative: must NOT launder the persisted store value with a bare cast + expect(source).not.toMatch(/homeSuggestionsDismissed\(\) as HomeSuggestionChipID\[\]/) + }) + test("uses the settings accessor for read and write (not raw store)", () => { expect(source).toContain("settings.general.homeSuggestionsEnabled()") expect(source).toContain("settings.general.homeSuggestionsDismissed()") expect(source).toContain("settings.general.setHomeSuggestionsDismissed(") - // negative: must NOT reach into the raw store or call setStore directly expect(source).not.toContain("settings.store.general") expect(source).not.toContain("settings.setStore(") }) @@ -46,6 +67,12 @@ describe("HomeSuggestionList source contract", () => { expect(source).toContain("HOME_SUGGESTION_CHIPS.map") }) + test("rest-state dismiss button is not clickable (pointer-events-none) and not in tab order", () => { + expect(source).toContain("pointer-events-none") + expect(source).toContain("group-hover:pointer-events-auto") + expect(source).toContain("tabIndex={-1}") + }) + test("renders nothing when there are no visible chips", () => { expect(source).toContain("visibleChips().length > 0") }) diff --git a/packages/app/src/components/home/home-suggestion-list.tsx b/packages/app/src/components/home/home-suggestion-list.tsx index 86330eb42..81b408fd1 100644 --- a/packages/app/src/components/home/home-suggestion-list.tsx +++ b/packages/app/src/components/home/home-suggestion-list.tsx @@ -1,9 +1,10 @@ -import { For, Show, createMemo, type Component } from "solid-js" +import { For, Show, createEffect, createMemo, type Component } from "solid-js" import { Icon } from "@opencode-ai/ui/icon" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" +import { setCursorPosition } from "@/components/prompt-input/editor-dom" import { HOME_SUGGESTION_CHIPS, resolveVisibleHomeSuggestions, @@ -12,10 +13,24 @@ import { const PROMPT_EDITOR_SELECTOR = '[data-component="prompt-input"]' -function focusComposerEditor() { +function focusComposerEditor(caretAt: number) { if (typeof document === "undefined") return const editor = document.querySelector(PROMPT_EDITOR_SELECTOR) - editor?.focus() + if (!editor) return + editor.focus() + setCursorPosition(editor, caretAt) +} + +const KNOWN_CHIP_IDS = new Set(HOME_SUGGESTION_CHIPS.map((chip) => chip.id)) + +function filterKnownIDs(raw: readonly string[]): HomeSuggestionChipID[] { + const out: HomeSuggestionChipID[] = [] + for (const value of raw) { + if (KNOWN_CHIP_IDS.has(value as HomeSuggestionChipID)) { + out.push(value as HomeSuggestionChipID) + } + } + return out } export const HomeSuggestionList: Component = () => { @@ -25,17 +40,26 @@ export const HomeSuggestionList: Component = () => { const sync = useSync() const sessionCount = createMemo(() => Object.keys(sync.data.session ?? {}).length) - // sync.ready guards against showing chips during the initial loading hydration — - // sync.data.session is an empty object while status === "loading", which would - // make every user (returning or new) momentarily look like a first-time visitor. - // sync.ready is a getter on the context (not a function). - const firstTimeVisitor = createMemo(() => sync.ready && sessionCount() === 0) + const seen = createMemo(() => settings.general.homeSuggestionsSeen()) + + // Flip seen=true on first hydrated state with sessions. Returning users + // who clean up all sessions will never re-enter first-time onboarding. + createEffect(() => { + if (!sync.ready) return + if (seen()) return + if (sessionCount() > 0) settings.general.setHomeSuggestionsSeen(true) + }) + + // sync.ready guards against showing chips during initial hydration, where + // sync.data.session is briefly empty and every user looks like a new visitor. + // sync.ready is a reactive getter on the context, not a function call. + const firstTimeVisitor = createMemo(() => sync.ready && sessionCount() === 0 && !seen()) const visibleIDs = createMemo(() => resolveVisibleHomeSuggestions({ firstTimeVisitor: firstTimeVisitor(), enabled: settings.general.homeSuggestionsEnabled(), - dismissed: settings.general.homeSuggestionsDismissed() as HomeSuggestionChipID[], + dismissed: filterKnownIDs(settings.general.homeSuggestionsDismissed()), }), ) @@ -46,18 +70,38 @@ export const HomeSuggestionList: Component = () => { type I18nKey = Parameters[0] + const markSeen = () => { + if (!seen()) settings.general.setHomeSuggestionsSeen(true) + } + const prefill = (text: string) => { + markSeen() + // Respect user-typed content. If they've already started a message, append + // the suggestion text with a space; otherwise replace. + if (prompt.dirty()) { + const existing = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + const suffix = existing.endsWith(" ") || existing.length === 0 ? "" : " " + const merged = `${existing}${suffix}${text}` + prompt.set([{ type: "text", content: merged, start: 0, end: merged.length }], merged.length) + requestAnimationFrame(() => focusComposerEditor(merged.length)) + return + } prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - requestAnimationFrame(focusComposerEditor) + requestAnimationFrame(() => focusComposerEditor(text.length)) } const dismissRow = (id: HomeSuggestionChipID) => { - const current = settings.general.homeSuggestionsDismissed() as HomeSuggestionChipID[] + markSeen() + const current = filterKnownIDs(settings.general.homeSuggestionsDismissed()) if (current.includes(id)) return settings.general.setHomeSuggestionsDismissed([...current, id]) } const dismissAll = () => { + markSeen() settings.general.setHomeSuggestionsDismissed(HOME_SUGGESTION_CHIPS.map((chip) => chip.id)) } @@ -97,7 +141,8 @@ export const HomeSuggestionList: Component = () => { { - settings.general.setHomeSuggestionsEnabled(checked) - if ( - checked && - settings.general.homeSuggestionsDismissed().length >= HOME_SUGGESTION_CHIPS.length - ) { - settings.general.setHomeSuggestionsDismissed([]) - } - }} + onChange={(checked) => settings.general.setHomeSuggestionsEnabled(checked)} />
From 7c68bf5a83436a8ffbb4e37386b81ed065e1e19f Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 15:56:49 +0800 Subject: [PATCH 10/30] fix(app): hide restore button when sessions exist, preserve dirty composer Round 4 crosscheck surfaced two remaining issues: - Claude P1: Restore in Settings was still a silent no-op for returning users (sessionCount > 0). Even after clearing both seen and dismissed, firstTimeVisitor stays false because of the sessionCount check. The button promised recovery that could not happen. Settings now consults useSync and hides the restore button once any session exists. - Claude P2 #5: prefill merged user-typed content with the suggestion by joining text parts, which silently dropped file/agent @-mentions. When prompt.dirty() is true, prefill now leaves the composer untouched and only refocuses. No corruption path remains. Also normalize sessionCount to sync.data.session.length (it is Session[], not a record map, confirmed via use-session-blockers.ts:33). --- .../onboarding/home-suggestion-chips.spec.ts | 14 ++++++++++++++ .../home/home-suggestion-list.test.ts | 10 ++++++++-- .../components/home/home-suggestion-list.tsx | 19 ++++++++----------- .../settings-general.home-suggestions.test.ts | 8 ++++++++ .../app/src/components/settings-general.tsx | 8 ++++++++ 5 files changed, 46 insertions(+), 13 deletions(-) diff --git a/packages/app/e2e/onboarding/home-suggestion-chips.spec.ts b/packages/app/e2e/onboarding/home-suggestion-chips.spec.ts index d1adf5a9c..10c7534a3 100644 --- a/packages/app/e2e/onboarding/home-suggestion-chips.spec.ts +++ b/packages/app/e2e/onboarding/home-suggestion-chips.spec.ts @@ -133,6 +133,20 @@ test("editing a prefilled suggestion preserves the user's edit on send", async ( await expect(page.getByText(/please be concise/)).toBeVisible() }) +test("clicking a chip with user-typed content does NOT overwrite the user content", async ({ page, project }) => { + await project.open() + + const editor = page.locator(promptSelector) + await editor.click() + await page.keyboard.type("my own draft text") + await expect(editor).toContainText("my own draft text") + + // click first chip — user content should be preserved (no merge, no overwrite) + await page.locator(suggestionListSelector).locator(rowSelector).first().click() + await expect(editor).toBeFocused() + await expect(editor).toContainText("my own draft text") +}) + test("settings toggle hides and restores the suggestion section via the real Settings UI", async ({ page, project, diff --git a/packages/app/src/components/home/home-suggestion-list.test.ts b/packages/app/src/components/home/home-suggestion-list.test.ts index ec658a2db..0d4b50b92 100644 --- a/packages/app/src/components/home/home-suggestion-list.test.ts +++ b/packages/app/src/components/home/home-suggestion-list.test.ts @@ -14,7 +14,7 @@ describe("HomeSuggestionList source contract", () => { test("computes firstTimeVisitor from sync.data.session count", () => { expect(source).toContain("sync.data.session") - expect(source).toMatch(/Object\.keys\([^)]*session[^)]*\)\.length/) + expect(source).toMatch(/sync\.data\.session\??\.length/) }) test("guards firstTimeVisitor on sync.ready to avoid flashing during hydration", () => { @@ -67,8 +67,14 @@ describe("HomeSuggestionList source contract", () => { expect(source).toContain("setCursorPosition") }) - test("respects user-typed content via prompt.dirty() before overwriting", () => { + test("respects user-typed content via prompt.dirty() (does not overwrite)", () => { + // Behavior: if prompt is dirty, do NOT call prompt.set with the suggestion + // text. Just focus the editor. Otherwise, prefill normally. We verify the + // dirty-branch body does NOT contain prompt.set(. expect(source).toContain("prompt.dirty()") + const dirtyBranch = source.match(/if \(prompt\.dirty\(\)\)\s*\{[\s\S]*?\}/) + expect(dirtyBranch).not.toBeNull() + expect(dirtyBranch![0]).not.toContain("prompt.set(") }) test("filters dismissed IDs against known chip IDs (no bare type cast)", () => { diff --git a/packages/app/src/components/home/home-suggestion-list.tsx b/packages/app/src/components/home/home-suggestion-list.tsx index d665ea55a..c301fa68d 100644 --- a/packages/app/src/components/home/home-suggestion-list.tsx +++ b/packages/app/src/components/home/home-suggestion-list.tsx @@ -5,6 +5,7 @@ import { usePrompt } from "@/context/prompt" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { setCursorPosition } from "@/components/prompt-input/editor-dom" +import { promptLength } from "@/components/prompt-input/history" import { HOME_SUGGESTION_CHIPS, resolveVisibleHomeSuggestions, @@ -39,7 +40,7 @@ export const HomeSuggestionList: Component = () => { const settings = useSettings() const sync = useSync() - const sessionCount = createMemo(() => Object.keys(sync.data.session ?? {}).length) + const sessionCount = createMemo(() => sync.data.session?.length ?? 0) const seen = createMemo(() => settings.general.homeSuggestionsSeen()) // Flip seen=true on first hydrated state with sessions. Returning users @@ -76,17 +77,13 @@ export const HomeSuggestionList: Component = () => { const prefill = (text: string) => { markSeen() - // Respect user-typed content. If they've already started a message, append - // the suggestion text with a space; otherwise replace. + // If the user has already started typing (or @-mentioned a file), do not + // overwrite their work. Just focus the editor and leave the composer + // untouched. They can clear it and click the chip again if they really + // want the suggestion. Naively merging would lose non-text parts like + // file/agent mentions, which is a worse failure mode than no-op here. if (prompt.dirty()) { - const existing = prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - const suffix = existing.endsWith(" ") || existing.length === 0 ? "" : " " - const merged = `${existing}${suffix}${text}` - prompt.set([{ type: "text", content: merged, start: 0, end: merged.length }], merged.length) - requestAnimationFrame(() => focusComposerEditor(merged.length)) + requestAnimationFrame(() => focusComposerEditor(promptLength(prompt.current()))) return } prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) diff --git a/packages/app/src/components/settings-general.home-suggestions.test.ts b/packages/app/src/components/settings-general.home-suggestions.test.ts index 14369eab5..596713d3e 100644 --- a/packages/app/src/components/settings-general.home-suggestions.test.ts +++ b/packages/app/src/components/settings-general.home-suggestions.test.ts @@ -21,6 +21,14 @@ describe("settings-general home suggestions row", () => { expect(source).toContain("homeSuggestionsSeen()") }) + test("restore button is hidden when sessions already exist (no-op recovery)", () => { + // Once any session is present, firstTimeVisitor is false regardless of + // dismissed/seen, so restoring would be a silent no-op. Settings must + // therefore consult sync.data.session length to decide whether to render. + expect(source).toContain("sync.data.session") + expect(source).toContain("sync.ready") + }) + test("restore action resets BOTH dismissed and seen", () => { // The single most important invariant: clicking restore must clear BOTH // state slots, otherwise the button is a silent no-op after section X. diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index a1b801736..688d6be65 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -18,6 +18,7 @@ import { sansInput, useSettings, } from "@/context/settings" +import { useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" import { Link } from "./link" import { SettingsList } from "./settings-list" @@ -39,6 +40,7 @@ export const SettingsGeneral: Component = () => { const platform = usePlatform() const params = useParams() const settings = useSettings() + const sync = useSync() onMount(() => { void theme.loadThemes() @@ -128,6 +130,12 @@ export const SettingsGeneral: Component = () => { 0 || settings.general.homeSuggestionsSeen()) } From f7582188c6057604b3c248c593fa942e4d8a7c4a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 16:00:30 +0800 Subject: [PATCH 11/30] fix(app): defer markSeen until prefill actually accepts the chip Round 5 P2: prefill flipped seen=true before the dirty-composer no-op check, so clicking a chip while the composer already had user text silently exited onboarding even though nothing happened. Move markSeen after the dirty-branch return so seen only flips when a chip click actually produced a prefill. Locked with an ordering assertion in the contract test. Round 5 was otherwise clean: Codex reported no findings, Claude reported zero P0/P1. This is the iterate-to-zero terminal state per the user directive. --- .../app/src/components/home/home-suggestion-list.test.ts | 9 ++++++++- .../app/src/components/home/home-suggestion-list.tsx | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/home/home-suggestion-list.test.ts b/packages/app/src/components/home/home-suggestion-list.test.ts index 0d4b50b92..c7987f238 100644 --- a/packages/app/src/components/home/home-suggestion-list.test.ts +++ b/packages/app/src/components/home/home-suggestion-list.test.ts @@ -45,10 +45,17 @@ describe("HomeSuggestionList source contract", () => { expect(match![0]).toContain("markSeen()") }) - test("chip click (prefill) DOES mark seen", () => { + test("chip click (prefill) DOES mark seen, but only AFTER the dirty no-op branch", () => { const match = source.match(/const prefill = [\s\S]*?\n \}/) expect(match).not.toBeNull() expect(match![0]).toContain("markSeen()") + // Order: prompt.dirty() check must come before markSeen() so a chip click + // that becomes a no-op (because the composer was already dirty) does not + // silently exit onboarding. + const dirtyIndex = match![0].indexOf("prompt.dirty()") + const seenIndex = match![0].indexOf("markSeen()") + expect(dirtyIndex).toBeGreaterThan(-1) + expect(seenIndex).toBeGreaterThan(dirtyIndex) }) test("exposes the documented data-component and data-action hooks for E2E", () => { diff --git a/packages/app/src/components/home/home-suggestion-list.tsx b/packages/app/src/components/home/home-suggestion-list.tsx index c301fa68d..965c36f82 100644 --- a/packages/app/src/components/home/home-suggestion-list.tsx +++ b/packages/app/src/components/home/home-suggestion-list.tsx @@ -76,16 +76,18 @@ export const HomeSuggestionList: Component = () => { } const prefill = (text: string) => { - markSeen() // If the user has already started typing (or @-mentioned a file), do not // overwrite their work. Just focus the editor and leave the composer // untouched. They can clear it and click the chip again if they really // want the suggestion. Naively merging would lose non-text parts like // file/agent mentions, which is a worse failure mode than no-op here. + // We also do NOT mark seen in this no-op path: the user hasn't actually + // engaged with onboarding, so flipping seen would silently exit them. if (prompt.dirty()) { requestAnimationFrame(() => focusComposerEditor(promptLength(prompt.current()))) return } + markSeen() prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) requestAnimationFrame(() => focusComposerEditor(text.length)) } From 98ef3b19187b744fe82081ccaf3db235ad5a93d4 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 16:09:33 +0800 Subject: [PATCH 12/30] fix(app): settings page must not depend on Sync provider Manual dev:desktop verification surfaced a P0 I missed across five crosscheck rounds: the Settings page renders outside the Sync provider, so calling useSync() in SettingsGeneral throws "Sync context must be used within a context provider" the moment the user opens Settings. This was added in the previous fix as an extra gate that hid the restore button for returning users. The right gate is simpler and does not need sync at all: the dismissed list is non-empty exactly when chips were hidden via either path (per-row or section), because dismissAll already writes all chip ids. For a returning user the createEffect auto-latches seen=true but leaves dismissed empty, so the button stays hidden and we never surface a no-op recovery. Restore still resets BOTH state slots so the section dismiss path actually unwinds. Verified via dev:desktop after the patch: Settings opens without crashing. Lesson saved to memory: visual verification catches provider-boundary bugs that source-substring contract tests cannot see. --- .../settings-general.home-suggestions.test.ts | 26 +++++++++---------- .../app/src/components/settings-general.tsx | 21 ++++++--------- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/packages/app/src/components/settings-general.home-suggestions.test.ts b/packages/app/src/components/settings-general.home-suggestions.test.ts index 596713d3e..efbbd6d6a 100644 --- a/packages/app/src/components/settings-general.home-suggestions.test.ts +++ b/packages/app/src/components/settings-general.home-suggestions.test.ts @@ -13,25 +13,23 @@ describe("settings-general home suggestions row", () => { expect(source).toContain("settings.general.setHomeSuggestionsEnabled(") }) - test("restore button is visible whenever any chip is dismissed OR seen was set", () => { - // Either source slot indicates "chips are currently hidden because of past - // user action" and a restore should be offered. Gating only on dismissed - // would leave the button hidden after a section dismiss (which writes seen). + test("restore button is gated on dismissed-non-empty (no useSync coupling)", () => { + // dismissAll writes all chip ids, so dismissed-non-empty is true exactly + // when chips were hidden via either path (per-row or section). For a + // returning user the createEffect auto-latches seen=true but leaves + // dismissed empty, so the button stays hidden and we never surface a + // no-op recovery. This keeps Settings independent of the Sync provider, + // which is critical because the Settings page renders outside it. expect(source).toContain("homeSuggestionsDismissed().length > 0") - expect(source).toContain("homeSuggestionsSeen()") - }) - - test("restore button is hidden when sessions already exist (no-op recovery)", () => { - // Once any session is present, firstTimeVisitor is false regardless of - // dismissed/seen, so restoring would be a silent no-op. Settings must - // therefore consult sync.data.session length to decide whether to render. - expect(source).toContain("sync.data.session") - expect(source).toContain("sync.ready") + // Settings page renders outside the Sync provider; importing useSync + // throws "Sync context must be used within a context provider". + expect(source).not.toContain("useSync") }) test("restore action resets BOTH dismissed and seen", () => { // The single most important invariant: clicking restore must clear BOTH - // state slots, otherwise the button is a silent no-op after section X. + // state slots, otherwise the button is a silent no-op after section X + // (which writes both dismissed and seen). expect(source).toContain("setHomeSuggestionsDismissed([])") expect(source).toContain("setHomeSuggestionsSeen(false)") }) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 688d6be65..01a9df1b0 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -18,7 +18,6 @@ import { sansInput, useSettings, } from "@/context/settings" -import { useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" import { Link } from "./link" import { SettingsList } from "./settings-list" @@ -40,7 +39,6 @@ export const SettingsGeneral: Component = () => { const platform = usePlatform() const params = useParams() const settings = useSettings() - const sync = useSync() onMount(() => { void theme.loadThemes() @@ -130,23 +128,20 @@ export const SettingsGeneral: Component = () => { 0 || - settings.general.homeSuggestionsSeen()) + settings.general.homeSuggestionsDismissed().length > 0 } > + {/* Gate on dismissed only: this is non-empty in exactly the two + paths a Restore would actually help (per-row dismissal and + section dismiss, since dismissAll writes all ids). For a + returning user the createEffect auto-latches seen=true but + leaves dismissed empty, so the button stays hidden and we + do not surface a no-op recovery. Restore still resets BOTH + state slots so the section dismiss path actually unwinds. */} -
    {(chip) => ( diff --git a/packages/app/src/components/home/home-suggestions-state.test.ts b/packages/app/src/components/home/home-suggestions-state.test.ts index ed4e4b95f..6a9e4cbbc 100644 --- a/packages/app/src/components/home/home-suggestions-state.test.ts +++ b/packages/app/src/components/home/home-suggestions-state.test.ts @@ -8,29 +8,23 @@ import { describe("resolveVisibleHomeSuggestions", () => { const allIDs = HOME_SUGGESTION_CHIPS.map((chip) => chip.id) - test("returns all chips when first-time + enabled + nothing dismissed", () => { - expect(resolveVisibleHomeSuggestions({ firstTimeVisitor: true, enabled: true, dismissed: [] })).toEqual(allIDs) + test("returns all chips for a first-time visitor with nothing dismissed", () => { + expect(resolveVisibleHomeSuggestions({ firstTimeVisitor: true, dismissed: [] })).toEqual(allIDs) }) test("returns empty when not a first-time visitor", () => { - expect(resolveVisibleHomeSuggestions({ firstTimeVisitor: false, enabled: true, dismissed: [] })).toEqual([]) - }) - - test("returns empty when feature is disabled", () => { - expect(resolveVisibleHomeSuggestions({ firstTimeVisitor: true, enabled: false, dismissed: [] })).toEqual([]) + expect(resolveVisibleHomeSuggestions({ firstTimeVisitor: false, dismissed: [] })).toEqual([]) }) test("filters dismissed chips while preserving original order", () => { const dismissed: HomeSuggestionChipID[] = ["news-brief"] - expect( - resolveVisibleHomeSuggestions({ firstTimeVisitor: true, enabled: true, dismissed }), - ).toEqual(allIDs.filter((id) => id !== "news-brief")) + expect(resolveVisibleHomeSuggestions({ firstTimeVisitor: true, dismissed })).toEqual( + allIDs.filter((id) => id !== "news-brief"), + ) }) test("returns empty when all three chips are dismissed", () => { - expect( - resolveVisibleHomeSuggestions({ firstTimeVisitor: true, enabled: true, dismissed: allIDs }), - ).toEqual([]) + expect(resolveVisibleHomeSuggestions({ firstTimeVisitor: true, dismissed: allIDs })).toEqual([]) }) test("HOME_SUGGESTION_CHIPS has stable IDs and three entries", () => { diff --git a/packages/app/src/components/home/home-suggestions-state.ts b/packages/app/src/components/home/home-suggestions-state.ts index 12d7f1627..4c0479562 100644 --- a/packages/app/src/components/home/home-suggestions-state.ts +++ b/packages/app/src/components/home/home-suggestions-state.ts @@ -13,13 +13,11 @@ export const HOME_SUGGESTION_CHIPS: readonly HomeSuggestionChip[] = [ export interface ResolveHomeSuggestionsInput { firstTimeVisitor: boolean - enabled: boolean dismissed: readonly HomeSuggestionChipID[] } export function resolveVisibleHomeSuggestions(input: ResolveHomeSuggestionsInput): HomeSuggestionChipID[] { if (!input.firstTimeVisitor) return [] - if (!input.enabled) return [] const dismissedSet = new Set(input.dismissed) return HOME_SUGGESTION_CHIPS.map((chip) => chip.id).filter((id) => !dismissedSet.has(id)) } diff --git a/packages/app/src/components/settings-general.home-suggestions.test.ts b/packages/app/src/components/settings-general.home-suggestions.test.ts deleted file mode 100644 index efbbd6d6a..000000000 --- a/packages/app/src/components/settings-general.home-suggestions.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { readFileSync } from "node:fs" - -describe("settings-general home suggestions row", () => { - const source = readFileSync("src/components/settings-general.tsx", "utf8") - - test("renders the suggestion toggle row with the documented i18n title", () => { - expect(source).toContain("settings.general.homeSuggestions") - }) - - test("wires the Switch to settings.general.homeSuggestionsEnabled accessor", () => { - expect(source).toContain("settings.general.homeSuggestionsEnabled()") - expect(source).toContain("settings.general.setHomeSuggestionsEnabled(") - }) - - test("restore button is gated on dismissed-non-empty (no useSync coupling)", () => { - // dismissAll writes all chip ids, so dismissed-non-empty is true exactly - // when chips were hidden via either path (per-row or section). For a - // returning user the createEffect auto-latches seen=true but leaves - // dismissed empty, so the button stays hidden and we never surface a - // no-op recovery. This keeps Settings independent of the Sync provider, - // which is critical because the Settings page renders outside it. - expect(source).toContain("homeSuggestionsDismissed().length > 0") - // Settings page renders outside the Sync provider; importing useSync - // throws "Sync context must be used within a context provider". - expect(source).not.toContain("useSync") - }) - - test("restore action resets BOTH dismissed and seen", () => { - // The single most important invariant: clicking restore must clear BOTH - // state slots, otherwise the button is a silent no-op after section X - // (which writes both dismissed and seen). - expect(source).toContain("setHomeSuggestionsDismissed([])") - expect(source).toContain("setHomeSuggestionsSeen(false)") - }) - - test("switch toggle is a plain on/off without auto-clear side effects", () => { - // The previous auto-clear-on-re-enable branch was dead code under the seen - // flag (clearing dismissed did not restore chips). The dedicated restore - // button now owns that responsibility; toggle stays simple. - expect(source).not.toMatch(/setHomeSuggestionsEnabled\(checked\)[\s\S]{0,200}setHomeSuggestionsDismissed\(\[\]\)/) - }) -}) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 01a9df1b0..c1e47ad20 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -120,42 +120,6 @@ export const SettingsGeneral: Component = () => { - -
    - 0 - } - > - {/* Gate on dismissed only: this is non-empty in exactly the two - paths a Restore would actually help (per-row dismissal and - section dismiss, since dismissAll writes all ids). For a - returning user the createEffect auto-latches seen=true but - leaves dismissed empty, so the button stays hidden and we - do not surface a no-op recovery. Restore still resets BOTH - state slots so the section dismiss path actually unwinds. */} - - - settings.general.setHomeSuggestionsEnabled(checked)} - /> -
    -
    - store.general?.homeSuggestionsEnabled, - defaultSettings.general.homeSuggestionsEnabled, - ), - setHomeSuggestionsEnabled(value: boolean) { - setStore("general", "homeSuggestionsEnabled", value) - }, homeSuggestionsDismissed: withFallback( () => store.general?.homeSuggestionsDismissed, defaultSettings.general.homeSuggestionsDismissed, @@ -305,19 +294,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setHomeSuggestionsDismissed(value: string[]) { setStore("general", "homeSuggestionsDismissed", value) }, - // homeSuggestionsSeen is a one-way bit: flips to true when the user - // has any session hydrated, clicks a chip, or dismisses the whole - // section. Per-row dismiss does NOT flip it (those are curation, not - // exit). Without this flag, a returning user who deletes all sessions - // would re-enter "first-time visitor" state and see onboarding chips - // again, which the design explicitly rejects. - homeSuggestionsSeen: withFallback( - () => store.general?.homeSuggestionsSeen, - defaultSettings.general.homeSuggestionsSeen, - ), - setHomeSuggestionsSeen(value: boolean) { - setStore("general", "homeSuggestionsSeen", value) - }, }, updates: { startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 20e4117e0..4d54b03ad 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -570,10 +570,8 @@ export const dict = { "home.recentProjects": "Recent projects", "home.hero.title": "What should we work on?", - "home.suggestion.section.label": "Prompt suggestions", - "home.suggestion.section.dismiss": "Dismiss all prompt suggestions", "home.suggestion.row.dismiss": "Dismiss this prompt", - "home.suggestion.analyze-spreadsheet": "Analyze a spreadsheet — surface key data and anomalies", + "home.suggestion.analyze-spreadsheet": "Analyze a spreadsheet and surface key data", "home.suggestion.news-brief": "Summarize today's news worth following into a brief", "home.suggestion.draft-email": "Draft a work email for me", @@ -884,9 +882,6 @@ export const dict = { "Show edit, write, and patch tool parts expanded by default in the timeline", "settings.general.row.lsp.title": "Language Server Protocol (LSP)", "settings.general.row.lsp.description": "Detect type errors and symbol references when editing code", - "settings.general.homeSuggestions": "Home prompt suggestions", - "settings.general.homeSuggestions.description": "Show 3 prompt suggestion rows under the composer for new visitors. Click a row to prefill the input.", - "settings.general.homeSuggestions.reset": "Restore all suggestions", "settings.general.webSearch.title": "Web search", "settings.general.webSearch.description": "Let agents look up fresh information online when needed", "settings.general.webSearch.chip.free": "Free (bundled)", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 0db7fd4a0..f503c93f1 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -540,8 +540,6 @@ export const dict = { "home.recentProjects": "最近项目", "home.hero.title": "今天我们做点什么?", - "home.suggestion.section.label": "提示词建议", - "home.suggestion.section.dismiss": "关闭所有提示词建议", "home.suggestion.row.dismiss": "关闭该提示词", "home.suggestion.analyze-spreadsheet": "帮我分析一份表格,找出关键数据和异常", "home.suggestion.news-brief": "查一下今天值得关注的新闻,整理一份简报", @@ -773,9 +771,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分", "settings.general.row.lsp.title": "语言服务器协议(LSP)", "settings.general.row.lsp.description": "修改代码时识别项目类型错误和符号引用", - "settings.general.homeSuggestions": "首页提示词建议", - "settings.general.homeSuggestions.description": "新用户在首页 composer 下方显示 3 行提示词建议;点击预填到输入框", - "settings.general.homeSuggestions.reset": "恢复全部建议", "settings.general.webSearch.title": "网页搜索", "settings.general.webSearch.description": "联网获取最新资料", "settings.general.webSearch.chip.free": "内置免费额度", diff --git a/packages/opencode/test/config/e2e-smoke-tagging.test.ts b/packages/opencode/test/config/e2e-smoke-tagging.test.ts index e5d71cfe0..01d8302fb 100644 --- a/packages/opencode/test/config/e2e-smoke-tagging.test.ts +++ b/packages/opencode/test/config/e2e-smoke-tagging.test.ts @@ -18,7 +18,6 @@ const expectedSmokeTests = [ "packages/app/e2e/onboarding/home-suggestion-chips.spec.ts:@smoke composer placeholder is the static home string", "packages/app/e2e/onboarding/home-suggestion-chips.spec.ts:@smoke home shows 3 suggestion rows for a first-time visitor", "packages/app/e2e/onboarding/home-suggestion-chips.spec.ts:@smoke per-row X dismisses one row and persists across reload", - "packages/app/e2e/onboarding/home-suggestion-chips.spec.ts:@smoke section X dismisses all three rows and hides the section", "packages/app/e2e/prompt/first-message-reply.spec.ts:@smoke first replied message in a new session renders without page errors", "packages/app/e2e/prompt/prompt.spec.ts:@smoke can send a prompt and receive a reply", "packages/app/e2e/release-notes/release-notes-toast.spec.ts:@smoke shows subtle toast when stored version is older than current", From 72adb71b1fb72e2afa8172203a1c26466b2b25c9 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 16:34:02 +0800 Subject: [PATCH 14/30] style(app): tighten home suggestion list visuals per design review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from dev:desktop manual verification: - Row width: container max-w was 640px (same as composer), but composer has its own visual padding, so rows looked wider than the input. Tighten to max-w-[520px] so rows sit visually inside the composer's bounding box. - Row hover: the full-width bg-row-hover-overlay highlight felt hollow on short text. Replace with a text-color hover (fg-muted → fg-strong) so the affordance reads as "this is interactive" without the loud band. - Dismiss X: size-4 icon felt heavy. Drop to size-3 inside a 20px square affordance with rounded corners and its own hover bg+color, so the control has clear self-feedback when targeted. --- .../app/src/components/home/home-suggestion-list.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/home/home-suggestion-list.tsx b/packages/app/src/components/home/home-suggestion-list.tsx index d330a52c6..b00ed6e35 100644 --- a/packages/app/src/components/home/home-suggestion-list.tsx +++ b/packages/app/src/components/home/home-suggestion-list.tsx @@ -78,15 +78,15 @@ export const HomeSuggestionList: Component = () => { 0}>
      {(chip) => ( -
    • +
    • )} From cd954ce78553fcec3016abae2a00c023a5d22e72 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 16:54:26 +0800 Subject: [PATCH 15/30] style(app): align home suggestion rows with picker-family DNA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the home-grown hover styling with the DESIGN.md picker-family / session-row idiom that PawWork uses everywhere else (session row at L399, picker contract at L415, settings nav row at L595, all share one hover language). The previous "fg-muted to fg-strong on text only" was imperceptible at 13px — confirmed live in dev:desktop. Visual changes: - Container width 520 to 640, aligned with the composer's outer edge so rows visually live inside the composer's bounding box rather than floating above as a separate, narrower strip. - Row shell: h-30 + radius-sm + px-2 (sitting in DESIGN.md L77 30-system). - Rest state: text-fg-weak (the quiet onboarding tone). - Hover: bg-row-hover-overlay (4% black, the PawWork picker-family standard) + text-fg-strong. This is the hover signal the rest of the product trains on — list rows should feel the same here. - Dismiss X: 30x30 ghost icon button + radius-md + 16px icon, hover uses bg-row-active-overlay (6%) per DESIGN.md L401 ("one tier deeper than the row to read separately"). 4px negative right margin lets it visually flush with the row edge. - focus-within: row gets the same overlay as hover so keyboard users see the focus target without needing the brand outline ring. No new tokens, no design deviations — pure reuse of established patterns. Picked variant B from the side-by-side preview after manual review. --- .../components/home/home-suggestion-list.tsx | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/home/home-suggestion-list.tsx b/packages/app/src/components/home/home-suggestion-list.tsx index b00ed6e35..aa4b819e6 100644 --- a/packages/app/src/components/home/home-suggestion-list.tsx +++ b/packages/app/src/components/home/home-suggestion-list.tsx @@ -41,8 +41,8 @@ export const HomeSuggestionList: Component = () => { const sync = useSync() const sessionCount = createMemo(() => sync.data.session?.length ?? 0) - // sync.ready guards against the brief hydration window where session is - // empty and every user looks new. sync.ready is a reactive getter. + // sync.ready guards the brief hydration window where session is empty and + // every user looks new. sync.ready is a reactive getter on the context. const firstTimeVisitor = createMemo(() => sync.ready && sessionCount() === 0) const visibleIDs = createMemo(() => @@ -60,6 +60,8 @@ export const HomeSuggestionList: Component = () => { type I18nKey = Parameters[0] const prefill = (text: string) => { + // Dirty composer: do not overwrite user-typed content (including @-mentions). + // Just focus the editor and leave it alone. if (prompt.dirty()) { requestAnimationFrame(() => focusComposerEditor(promptLength(prompt.current()))) return @@ -78,15 +80,18 @@ export const HomeSuggestionList: Component = () => { 0}>
        {(chip) => ( -
      • +
      • )} From 8e58682724bd1b3ced59d416e24f1d666cfb9de8 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 17:04:23 +0800 Subject: [PATCH 16/30] refactor(home): inset rows and add breathing gap to suggestion list Composer is max-w-[640px] with 1px border + 16px inner padding, so its text frame starts 17px inside the container. Insetting the row container by 16px on each side (max-w-[608px]) keeps row hover-overlay inside the composer's visible text frame instead of overshooting it. Adds gap-1 (4px) between rows per DESIGN.md L548 list-items rhythm so the three rows breathe instead of reading as a packed menu. --- .../app/src/components/home/home-suggestion-list.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/home/home-suggestion-list.tsx b/packages/app/src/components/home/home-suggestion-list.tsx index aa4b819e6..cbd6ba063 100644 --- a/packages/app/src/components/home/home-suggestion-list.tsx +++ b/packages/app/src/components/home/home-suggestion-list.tsx @@ -80,12 +80,13 @@ export const HomeSuggestionList: Component = () => { 0}>
        -
          +
            {(chip) => (
          • From 7fd6265ac30aa9290019b046ccc15c058cbdcff2 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 17 May 2026 17:42:28 +0800 Subject: [PATCH 17/30] feat(home): split chip label and prefill prompt, refocus tasks on OfficeCLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously each suggestion row showed the same text it prefilled into the composer, so the chip had no room to be a short hook and the prefill had no room to give the agent task context. Splits chip into a short labelKey (what the user sees in the row) and a longer promptKey (what gets prefilled on click). Replaces the three demo tasks to lean on PawWork's bundled officecli binary, which is the real local-agent differentiator: - folder-organize: organize a folder, propose plan before acting - excel-analysis: surface key data, outliers, trends via officecli - ppt-outline: generate a PPT outline from Word/PDF/Markdown via officecli Each prompt names the task, lists outputs, and points at officecli where relevant, but does not over-prescribe how the agent should ask clarifying questions — the agent will naturally ask for the file path on its own. Labels are written as a substring of their prompts so the existing e2e "row text appears in composer after click" assertion still holds. --- .../components/home/home-suggestion-list.tsx | 4 +-- .../home/home-suggestions-state.test.ts | 11 ++++---- .../components/home/home-suggestions-state.ts | 28 +++++++++++++++---- packages/app/src/i18n/en.ts | 12 ++++++-- packages/app/src/i18n/zh.ts | 12 ++++++-- 5 files changed, 49 insertions(+), 18 deletions(-) diff --git a/packages/app/src/components/home/home-suggestion-list.tsx b/packages/app/src/components/home/home-suggestion-list.tsx index cbd6ba063..eeff23e91 100644 --- a/packages/app/src/components/home/home-suggestion-list.tsx +++ b/packages/app/src/components/home/home-suggestion-list.tsx @@ -93,11 +93,11 @@ export const HomeSuggestionList: Component = () => {