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..2f9aeadd1 --- /dev/null +++ b/packages/app/e2e/onboarding/home-suggestion-chips.spec.ts @@ -0,0 +1,230 @@ +import type { Page } from "@playwright/test" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { modKey } from "../utils" + +const SUGGESTION_LIST_SELECTOR = '[data-component="home-suggestion-list"]' +const ROW_SELECTOR = '[data-action="home-suggestion-row"]' +const ROW_DISMISS_SELECTOR = '[data-action="home-suggestion-row-dismiss"]' + +async function readDismissedFromStorage(page: Page): Promise { + // settings.v3 is persisted via the settings store (utils/persist.ts) — in the + // e2e web context this hits localStorage directly with key "settings.v3". + return await page.evaluate(() => { + try { + const raw = localStorage.getItem("settings.v3") + if (!raw) return [] + const parsed = JSON.parse(raw) + return parsed?.general?.homeSuggestionsDismissed ?? [] + } catch { + return [] + } + }) +} + +test("@smoke home shows 3 suggestion rows for a first-time visitor", async ({ page, project }) => { + await project.open() + + const list = page.locator(SUGGESTION_LIST_SELECTOR) + await expect(list).toBeVisible() + await expect(list.locator(ROW_SELECTOR)).toHaveCount(3) +}) + +test("@smoke clicking a suggestion row prefills the composer", async ({ page, project }) => { + await project.open() + + const list = page.locator(SUGGESTION_LIST_SELECTOR) + const firstRow = list.locator(ROW_SELECTOR).first() + const text = (await firstRow.innerText()).trim() + await firstRow.click() + + const editor = page.locator(promptSelector) + await expect(editor).toBeFocused() + // contenteditable rendered via renderEditorWithCursor may include zero-width + // chars or wrapping spans, so toHaveText (strict) is flaky. toContainText + // asserts substring after textContent normalization which is right here. + await expect(editor).toContainText(text) +}) + +test("@smoke per-row X dismisses one row and persists across reload", async ({ page, project }) => { + await project.open() + + const list = page.locator(SUGGESTION_LIST_SELECTOR) + await expect(list.locator(ROW_SELECTOR)).toHaveCount(3) + + const firstRow = list.locator(ROW_SELECTOR).first() + await firstRow.hover() + await list.locator(ROW_DISMISS_SELECTOR).first().click() + + await expect(list.locator(ROW_SELECTOR)).toHaveCount(2) + + await page.reload() + await expect(page.locator(SUGGESTION_LIST_SELECTOR).locator(ROW_SELECTOR)).toHaveCount(2) +}) + +test("@smoke composer placeholder is the static home string", async ({ page, project }) => { + await project.open() + + const editor = page.locator(promptSelector) + await expect(editor).toBeVisible() + + // LanguageProvider persists under "pawwork.global.dat:language" with shape + // { locale: "zh" | "en" } (see packages/app/src/context/language.tsx). Falls + // back to "en" when unset, matching detectLocale()'s final return in a + // CI runner where navigator.language is "en-US". + const locale = await page.evaluate(() => { + const raw = localStorage.getItem("pawwork.global.dat:language") + if (!raw) return "en" + try { + const parsed = JSON.parse(raw) as { locale?: string } + return parsed.locale?.startsWith?.("zh") ? "zh" : "en" + } catch { + return "en" + } + }) + const label = await editor.getAttribute("aria-label") + // i18n source: packages/app/src/i18n/{zh,en}.ts → prompt.placeholder.home + if (locale === "zh") { + expect(label).toBe("输入你的任务,或 @ 引用文件") + } else { + expect(label).toBe("Type your task, or @ to mention files") + } +}) + +test("dismissing all 3 rows hides the section entirely", async ({ page, project }) => { + await project.open() + const list = page.locator(SUGGESTION_LIST_SELECTOR) + await expect(list.locator(ROW_SELECTOR)).toHaveCount(3) + + for (let i = 0; i < 3; i++) { + const firstRow = list.locator(ROW_SELECTOR).first() + await firstRow.hover() + await list.locator(ROW_DISMISS_SELECTOR).first().click() + } + + await expect(page.locator(SUGGESTION_LIST_SELECTOR)).toHaveCount(0) + + await page.reload() + await expect(page.locator(SUGGESTION_LIST_SELECTOR)).toHaveCount(0) +}) + +test("used chip is gone but unused chips still appear on home after session creation", async ({ + page, + project, + assistant, +}) => { + await project.open() + await assistant.reply("unused chips remain reply") + + const list = page.locator(SUGGESTION_LIST_SELECTOR) + const rows = list.locator(ROW_SELECTOR) + await expect(rows).toHaveCount(3) + + const firstChipID = await rows.first().getAttribute("data-chip-id") + expect(firstChipID).toBeTruthy() + + await rows.first().click() + await page.keyboard.press("Enter") + await expect.poll(() => page.url(), { timeout: 30_000 }).toContain("/session/") + + // Back to home: chips are NOT gated by sessionCount, so the unused two remain. + await project.open() + await expect(list.locator(ROW_SELECTOR)).toHaveCount(2) + const remainingIDs = await list + .locator(ROW_SELECTOR) + .evaluateAll((els) => els.map((el) => el.getAttribute("data-chip-id"))) + expect(remainingIDs).not.toContain(firstChipID) +}) + +test("editing a prefilled suggestion preserves the user's edit on send", async ({ page, project, assistant }) => { + await project.open() + await assistant.reply("edited reply") + + const editor = page.locator(promptSelector) + await page.locator(SUGGESTION_LIST_SELECTOR).locator(ROW_SELECTOR).first().click() + await expect(editor).toBeFocused() + + await page.keyboard.type(" please be concise") + await page.keyboard.press("Enter") + + await expect.poll(() => page.url(), { timeout: 30_000 }).toContain("/session/") + await expect(page.getByText(/please be concise/)).toBeVisible() +}) + +test("clicking another suggestion replaces the previous prefill", async ({ page, project }) => { + await project.open() + + const list = page.locator(SUGGESTION_LIST_SELECTOR) + const editor = page.locator(promptSelector) + const rows = list.locator(ROW_SELECTOR) + + const firstText = (await rows.first().innerText()).trim() + const secondText = (await rows.nth(1).innerText()).trim() + + await rows.first().click() + await expect(editor).toContainText(firstText) + + await rows.nth(1).click() + await expect(editor).toContainText(secondText) + await expect(editor).not.toContainText(firstText) +}) + +test("using a chip via send auto-dismisses it for capability discovery", async ({ page, project, assistant }) => { + await project.open() + await assistant.reply("auto-dismiss reply") + + const firstRow = page.locator(SUGGESTION_LIST_SELECTOR).locator(ROW_SELECTOR).first() + const firstChipID = await firstRow.getAttribute("data-chip-id") + expect(firstChipID).toBeTruthy() + + await firstRow.click() + await page.keyboard.press("Enter") + await expect.poll(() => page.url(), { timeout: 30_000 }).toContain("/session/") + + const dismissed = await readDismissedFromStorage(page) + expect(dismissed).toContain(firstChipID!) +}) + +test("switching chips before send dismisses only the last selection", async ({ page, project, assistant }) => { + await project.open() + await assistant.reply("only-last reply") + + const rows = page.locator(SUGGESTION_LIST_SELECTOR).locator(ROW_SELECTOR) + const firstChipID = await rows.first().getAttribute("data-chip-id") + const secondChipID = await rows.nth(1).getAttribute("data-chip-id") + + await rows.first().click() + await rows.nth(1).click() + await page.keyboard.press("Enter") + await expect.poll(() => page.url(), { timeout: 30_000 }).toContain("/session/") + + const dismissed = await readDismissedFromStorage(page) + expect(dismissed).toContain(secondChipID!) + expect(dismissed).not.toContain(firstChipID!) +}) + +test("clicking a chip then sending any content dismisses it (sticky source)", async ({ page, project, assistant }) => { + await project.open() + await assistant.reply("sticky source reply") + + const editor = page.locator(promptSelector) + const firstRow = page.locator(SUGGESTION_LIST_SELECTOR).locator(ROW_SELECTOR).first() + const firstChipID = await firstRow.getAttribute("data-chip-id") + expect(firstChipID).toBeTruthy() + + await firstRow.click() + await expect(editor).toBeFocused() + + // Drain the prefill, then type user-authored content. The chip source is + // sticky once clicked — sending any prompt afterwards still dismisses it + // (the user engaged with the suggestion and chose a direction, no need to + // keep pitching it). + await page.keyboard.press(`${modKey}+A`) + await page.keyboard.press("Backspace") + await page.keyboard.type("my own prompt content") + await page.keyboard.press("Enter") + await expect.poll(() => page.url(), { timeout: 30_000 }).toContain("/session/") + + const dismissed = await readDismissedFromStorage(page) + expect(dismissed).toContain(firstChipID!) +}) 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..879d97821 --- /dev/null +++ b/packages/app/src/components/home/home-suggestion-list.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" + +describe("HomeSuggestionList source contract", () => { + const source = readFileSync("src/components/home/home-suggestion-list.tsx", "utf8") + + test("wires the helper, prompt, settings, sync, and language contexts", () => { + expect(source).toContain("resolveVisibleHomeSuggestions") + expect(source).toContain("usePrompt") + expect(source).toContain("useSettings") + expect(source).toContain("useSync") + expect(source).toContain("useLanguage") + }) + + test("observes sessionCount only for the auto-dismiss effect, not as visibility gate", () => { + expect(source).toContain("sync.data.session") + expect(source).toMatch(/sync\.data\.session\??\.length/) + // BOTH stores must gate visibility on desktop because sync and settings + // are independent async hydrations; reading dismissed before settings is + // ready returns the withFallback([]) default and re-shows dismissed chips. + expect(source).toContain("sync.ready") + expect(source).toContain("settings.ready()") + // No per-project gate: visibility no longer derives from sessionCount === 0. + expect(source).not.toMatch(/firstTimeVisitor/) + }) + + test("exposes the documented data-component and data-action hooks for E2E", () => { + expect(source).toContain('data-component="home-suggestion-list"') + expect(source).toContain('data-action="home-suggestion-row"') + expect(source).toContain('data-action="home-suggestion-row-dismiss"') + }) + + test("does NOT render a section-level dismiss (only per-row X remains)", () => { + expect(source).not.toContain("home-suggestion-section-dismiss") + expect(source).not.toContain("home.suggestion.section") + }) + + test("does NOT couple to homeSuggestionsEnabled or homeSuggestionsSeen (those were removed)", () => { + expect(source).not.toContain("homeSuggestionsEnabled") + expect(source).not.toContain("homeSuggestionsSeen") + expect(source).not.toContain("setHomeSuggestionsSeen") + }) + + test("prefills the composer via prompt.set and focuses the editor", () => { + expect(source).toContain("prompt.set([") + expect(source).toContain('[data-component="prompt-input"]') + }) + + test("explicitly restores caret position after focus so follow-up typing works deterministically", () => { + expect(source).toContain("setCursorPosition") + }) + + test("prefill unconditionally replaces composer content (no dirty-guard skip)", () => { + // Chip clicks must always overwrite so the click-A-then-B exploration works. + // The previous "respect user-typed content" guard was replaced by per-chip + // currentChipSource tracking + auto-dismiss on send (see spec § Chip Lifecycle). + expect(source).not.toMatch(/if \(prompt\.dirty\(\)\)\s*\{[\s\S]{0,200}return/) + }) + + test("tracks chip source and auto-dismisses on session create", () => { + expect(source).toContain("currentChipSource") + expect(source).toContain("setCurrentChipSource") + expect(source).toMatch(/sessionCount\(\)/) + expect(source).toMatch(/dismissRow\(source\)/) + }) + + test("filters dismissed IDs against known chip IDs (no bare type cast)", () => { + expect(source).toContain("filterKnownIDs") + expect(source).not.toMatch(/homeSuggestionsDismissed\(\) as HomeSuggestionChipID\[\]/) + }) + + test("uses the settings accessor for read and write (not raw store)", () => { + expect(source).toContain("settings.general.homeSuggestionsDismissed()") + expect(source).toContain("settings.general.setHomeSuggestionsDismissed(") + expect(source).not.toContain("settings.store.general") + expect(source).not.toContain("settings.setStore(") + }) + + test("rest-state dismiss button is not clickable (pointer-events-none) but stays keyboard-reachable", () => { + expect(source).toContain("pointer-events-none") + expect(source).toContain("group-hover:pointer-events-auto") + expect(source).toContain("focus-visible:pointer-events-auto") + // Must NOT be excluded from the tab order: keyboard-only users need a path + // to dismiss a chip. Reveal-on-focus handles the visual hiding. + expect(source).not.toContain("tabIndex={-1}") + }) + + test("renders nothing when there are no visible chips", () => { + expect(source).toContain("visibleChips().length > 0") + }) + + test("uses i18n keys for chip text and aria-label", () => { + expect(source).toContain("home.suggestion.row.dismiss") + }) +}) 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..d71b84351 --- /dev/null +++ b/packages/app/src/components/home/home-suggestion-list.tsx @@ -0,0 +1,157 @@ +import { For, Show, createEffect, createMemo, createSignal, 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, + type HomeSuggestionChipID, +} from "./home-suggestions-state" + +const PROMPT_EDITOR_SELECTOR = '[data-component="prompt-input"]' + +function focusComposerEditor(caretAt: number) { + if (typeof document === "undefined") return + const editor = document.querySelector(PROMPT_EDITOR_SELECTOR) + 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 = () => { + const language = useLanguage() + const prompt = usePrompt() + const settings = useSettings() + const sync = useSync() + + // sessionCount is observed only for the auto-dismiss side effect below — + // it does NOT gate visibility. Chips are pure capability discovery: shown + // until each is individually dismissed, regardless of current workspace. + const sessionCount = createMemo(() => sync.data.session?.length ?? 0) + + const visibleIDs = createMemo(() => { + // Wait for BOTH stores. On desktop, sync and settings are separate async + // hydrations (see packages/app/src/utils/persist.ts AsyncStorage branch), + // so sync.ready alone does not imply settings.ready(). The dismissed + // accessor uses a withFallback([]) default, which would briefly read as + // an empty list and re-show chips the user has already dismissed. + if (!sync.ready || !settings.ready()) return [] + return resolveVisibleHomeSuggestions({ + dismissed: filterKnownIDs(settings.general.homeSuggestionsDismissed()), + }) + }) + + const visibleChips = createMemo(() => { + const ids = new Set(visibleIDs()) + return HOME_SUGGESTION_CHIPS.filter((chip) => ids.has(chip.id)) + }) + + type I18nKey = Parameters[0] + + // ─── Chip lifecycle state machine (see spec § Chip Lifecycle) ─── + // currentChipSource tracks "the chip this composer content came from." + // null → no chip has been clicked yet on this mount + // X → composer was last prefilled by chip X (sticks through user edits + // and clears; replaced when another chip is clicked) + // Transitions: + // chip X click → setSource(X) + // chip Y click after X → setSource(Y) + // user edits / clears → no change (sticky) + // Side effect: when sessionCount transitions upward with source set, the chip + // graduated into a real task — dismiss it globally so capability discovery + // doesn't re-pitch it on the next visit or workspace. + // + // The previous "drain composer also clears source" branch was removed because + // it forced the effect to subscribe to prompt.dirty(), which emits on every + // keystroke. On the home cold path that turned the effect into a hot loop + // visible in perf-probe-baseline (frame_gap_max jump). The only behavior + // difference: a user who clicks a chip, then types their own thing and sends, + // now sees that chip dismissed too — acceptable since they engaged with it. + // ──────────────────────────────────────────────────────────────── + const [currentChipSource, setCurrentChipSource] = createSignal(null) + + const dismissRow = (id: HomeSuggestionChipID) => { + const current = filterKnownIDs(settings.general.homeSuggestionsDismissed()) + if (current.includes(id)) return + settings.general.setHomeSuggestionsDismissed([...current, id]) + } + + createEffect((prev) => { + const count = sessionCount() + if (prev !== undefined && count > prev) { + const source = currentChipSource() + if (source) dismissRow(source) + setCurrentChipSource(null) + } + return count + }, undefined) + + const prefill = (chipID: HomeSuggestionChipID, text: string) => { + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + setCurrentChipSource(chipID) + requestAnimationFrame(() => focusComposerEditor(text.length)) + } + + return ( + 0}> +
+
    + + {(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 new file mode 100644 index 000000000..7f561c614 --- /dev/null +++ b/packages/app/src/components/home/home-suggestions-state.test.ts @@ -0,0 +1,35 @@ +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 nothing is dismissed", () => { + expect(resolveVisibleHomeSuggestions({ dismissed: [] })).toEqual(allIDs) + }) + + test("filters dismissed chips while preserving original order", () => { + const dismissed: HomeSuggestionChipID[] = ["excel-analysis"] + expect(resolveVisibleHomeSuggestions({ dismissed })).toEqual(allIDs.filter((id) => id !== "excel-analysis")) + }) + + test("returns empty when all three chips are dismissed", () => { + expect(resolveVisibleHomeSuggestions({ dismissed: allIDs })).toEqual([]) + }) + + test("HOME_SUGGESTION_CHIPS has stable IDs and three entries", () => { + expect(HOME_SUGGESTION_CHIPS).toHaveLength(3) + expect(allIDs).toEqual(["folder-organize", "excel-analysis", "ppt-outline"]) + }) + + test("each chip exposes stable label and prompt i18n keys", () => { + for (const chip of HOME_SUGGESTION_CHIPS) { + expect(chip.labelKey).toMatch(/^home\.suggestion\..+\.label$/) + expect(chip.promptKey).toMatch(/^home\.suggestion\..+\.prompt$/) + } + }) +}) 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..9c7b016a1 --- /dev/null +++ b/packages/app/src/components/home/home-suggestions-state.ts @@ -0,0 +1,42 @@ +export type HomeSuggestionChipID = "folder-organize" | "excel-analysis" | "ppt-outline" + +export interface HomeSuggestionChip { + id: HomeSuggestionChipID + // Short text shown in the row. Should be a substring of the prompt so the + // existing e2e prefill assertion (editor contains row text) still holds. + labelKey: string + // Full prefilled prompt sent to the agent on click. Substantially longer + // than labelKey by design: gives the agent task verb, output spec, and tool + // hint, while showing the user what a good prompt looks like. + promptKey: string +} + +export const HOME_SUGGESTION_CHIPS: readonly HomeSuggestionChip[] = [ + { + id: "folder-organize", + labelKey: "home.suggestion.folder-organize.label", + promptKey: "home.suggestion.folder-organize.prompt", + }, + { + id: "excel-analysis", + labelKey: "home.suggestion.excel-analysis.label", + promptKey: "home.suggestion.excel-analysis.prompt", + }, + { + id: "ppt-outline", + labelKey: "home.suggestion.ppt-outline.label", + promptKey: "home.suggestion.ppt-outline.prompt", + }, +] + +export interface ResolveHomeSuggestionsInput { + dismissed: readonly HomeSuggestionChipID[] +} + +// Pure capability-discovery: chips show until each is individually dismissed +// (user-X'd or auto-marked after use). No per-project gating — switching +// workspaces does not reset what the user has already engaged with. +export function resolveVisibleHomeSuggestions(input: ResolveHomeSuggestionsInput): HomeSuggestionChipID[] { + 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/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/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index c0eac4636..03d670795 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -1,6 +1,15 @@ -import { Show, type JSX } from "solid-js" +import { Show, Suspense, lazy, type JSX } from "solid-js" import { useLanguage } from "@/context/language" +// Lazy-loaded so the module + its reactive setup (4 contexts, createEffect with +// prompt.dirty + sessionCount tracking, For-loop chip render) doesn't run on +// the home's cold paint path. perf-probe-baseline showed +183ms frame_gap_max +// on homepage-cold and +267ms on tool-default-open-heavy-bash (both go through +// project.open() → home first) when this was mounted eagerly. +const HomeSuggestionList = lazy(() => + import("@/components/home/home-suggestion-list").then((module) => ({ default: module.HomeSuggestionList })), +) + type ComposerCtx = { onModeChange: (mode: "normal" | "shell") => void } @@ -23,6 +32,9 @@ export function NewSessionView(props: { composer?: (ctx: ComposerCtx) => JSX.Ele
{props.composer!({ onModeChange: () => {} })}
+ + + diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 38d46e73b..4a3bc9b5c 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -33,6 +33,7 @@ export interface Settings { editToolPartsExpanded: boolean lspEnabled: boolean webSearchEnabled: boolean + homeSuggestionsDismissed: string[] } updates: { startup: boolean @@ -114,6 +115,7 @@ const defaultSettings: Settings = { editToolPartsExpanded: false, lspEnabled: false, webSearchEnabled: true, + homeSuggestionsDismissed: [], }, updates: { startup: true, @@ -285,6 +287,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setWebSearchEnabled(value: boolean) { setStore("general", "webSearchEnabled", 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 9bf1ed771..4ec8b7eae 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", @@ -597,6 +570,16 @@ export const dict = { "home.recentProjects": "Recent projects", "home.hero.title": "What should we work on?", + "home.suggestion.row.dismiss": "Dismiss this suggestion", + "home.suggestion.folder-organize.label": "Organize a folder", + "home.suggestion.folder-organize.prompt": + "Organize a folder: group similar files into subfolders, flag duplicates and stale files, rename messy names. Scan first and propose a plan for me to approve.", + "home.suggestion.excel-analysis.label": "Analyze an Excel spreadsheet", + "home.suggestion.excel-analysis.prompt": + "Analyze an Excel spreadsheet: surface key data points, outliers, trends worth noting, and a clear next step.", + "home.suggestion.ppt-outline.label": "Generate a PPT outline", + "home.suggestion.ppt-outline.prompt": + "Generate a PPT outline from a source doc: sections, each slide's title and 3-5 bullets, plus key chart/data suggestions. Source can be Word, PDF, or Markdown.", "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 b727bb318..8084afe53 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 或文本文件拖放到此处", @@ -566,6 +540,16 @@ export const dict = { "home.recentProjects": "最近项目", "home.hero.title": "今天我们做点什么?", + "home.suggestion.row.dismiss": "关闭该建议", + "home.suggestion.folder-organize.label": "整理一个文件夹", + "home.suggestion.folder-organize.prompt": + "帮我整理一个文件夹:把同类文件归到子目录、标记重复或过时的文件、给命名乱的提新名字。先扫描内容,输出一份方案让我确认再动手。", + "home.suggestion.excel-analysis.label": "分析一份 Excel 表格", + "home.suggestion.excel-analysis.prompt": + "帮我分析一份 Excel 表格:找出关键数据点、异常值和趋势,给我一条清晰的下一步建议。", + "home.suggestion.ppt-outline.label": "从一份资料生成 PPT 大纲", + "home.suggestion.ppt-outline.prompt": + "帮我从一份资料生成 PPT 大纲:章节结构、每页标题和 3-5 条要点、关键图表或数据建议。支持从 Word、PDF 或 Markdown 资料抽取。", "session.tab.session": "会话", "session.tab.review": "审查", diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 79d120f72..82c13ca31 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -9,7 +9,7 @@ import { Log } from "@opencode-ai/core/util/log" import { lazy } from "@opencode-ai/util/lazy" import { Shell } from "@/shell/shell" import { Plugin } from "@/plugin" -import { envValueCaseInsensitive, withoutInternalServerAuthEnv } from "@/util/env" +import { envValueCaseInsensitive, prependBundledTools, stripPathKeys, withoutInternalServerAuthEnv } from "@/util/env" import { Process } from "@/util/process" import { PtyID } from "./schema" import { Effect, Layer, Context } from "effect" @@ -257,13 +257,25 @@ export namespace Pty { const cwd = input.cwd || s.dir const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} }) + const shellEnvRecord = shell.env as Record + const inputEnvRecord = (input.env ?? {}) as Record + // Resolve PATH case-insensitively (Windows exposes "Path") and strip + // every casing from the merged env before writing back a canonical + // PATH, so the spawned PTY does not receive duplicate keys. + const currentPath = + envValueCaseInsensitive(shellEnvRecord, "PATH") ?? + envValueCaseInsensitive(inputEnvRecord, "PATH") ?? + envValueCaseInsensitive(process.env, "PATH") ?? + "" const env = withoutInternalServerAuthEnv({ ...process.env, - ...input.env, - ...shell.env, + ...inputEnvRecord, + ...shellEnvRecord, TERM: "xterm-256color", OPENCODE_TERMINAL: "1", } as Record) + stripPathKeys(env) + env.PATH = prependBundledTools(currentPath) // bun-pty merges with the parent process environment internally, so // deleting these keys is not enough for PTY sessions. Override with // empty values to prevent PawWork's internal server credentials from diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a64678bc3..2f4620ef3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -48,7 +48,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" -import { withoutInternalServerAuthEnv } from "@/util/env" +import { envValueCaseInsensitive, prependBundledTools, stripPathKeys, withoutInternalServerAuthEnv } from "@/util/env" import { Cause, Deferred, Effect, Exit, Layer, Option, Scope, Context } from "effect" import { EffectLogger } from "@/effect" import { InstanceState } from "@/effect" @@ -1209,12 +1209,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the plugin.trigger("shell.env", { cwd, sessionID: input.sessionID, callID: part.callID }, { env: {} }), ) + const shellEnvRecord = shellEnv.env as Record + // Resolve PATH case-insensitively (Windows uses "Path") and strip + // every casing from the merged env before writing back a canonical + // PATH, so the spawned child does not receive duplicate keys. + const currentPath = + envValueCaseInsensitive(shellEnvRecord, "PATH") ?? envValueCaseInsensitive(process.env, "PATH") ?? "" const env = withoutInternalServerAuthEnv({ ...process.env, - ...shellEnv.env, + ...shellEnvRecord, TERM: "dumb", + OFFICECLI_SKIP_UPDATE: "1", ...(shellName === "zsh" || shellName === "bash" ? { OPENCODE_SHELL_CWD: cwd } : {}), - }) + } as Record) + stripPathKeys(env) + env.PATH = prependBundledTools(currentPath) const cmd = ChildProcess.make(sh, args, { cwd, diff --git a/packages/opencode/src/session/prompt/pawwork.txt b/packages/opencode/src/session/prompt/pawwork.txt index 5390ebd25..e5d7dd439 100644 --- a/packages/opencode/src/session/prompt/pawwork.txt +++ b/packages/opencode/src/session/prompt/pawwork.txt @@ -49,6 +49,10 @@ Pick the right helper before acting: If the user has already specified a path, execute it directly without re-asking. Otherwise reach for these helpers only when their specific trigger applies — handle ordinary work in this thread. +# Bundled capabilities + +PawWork ships with `officecli`, a CLI built for AI agents to read and write Microsoft Office files on the user's machine: `.docx`, `.xlsx`, and `.pptx`. It is already on PATH inside the bash tool. When a task requires reading or modifying one of these file types on the local disk, prefer `officecli` over Python libraries (`python-docx`, `openpyxl`, `python-pptx`) or generic file reads. Run `officecli help` or `officecli --help` to discover subcommands and arguments before assuming syntax. Do not invoke it for tasks that are about Office topics in the abstract (e.g. layout advice) rather than operating on a real file. + # Communication Use the user's language. Be concise, direct, and specific. diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index fd54433de..956302a38 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -23,7 +23,7 @@ import { Plugin } from "@/plugin" import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" -import { withoutInternalServerAuthEnv } from "@/util/env" +import { envValueCaseInsensitive, prependBundledTools, stripPathKeys, withoutInternalServerAuthEnv } from "@/util/env" import { Global } from "@opencode-ai/core/global" import { assertExternalDirectoryEffect, resolveExternalPathForPermission } from "./external-directory" import { InstanceState } from "@/effect/instance-state" @@ -468,17 +468,23 @@ export const BashTool = Tool.define( { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }, ) - // Prepend bundled tools directory to PATH so the agent can call them - const resourcesPath = (process as any).resourcesPath as string | undefined - const bundledToolsDir = resourcesPath ? path.join(resourcesPath, "tools") : "" const extraEnv = extra.env as Record - const currentPath = extraEnv.PATH || process.env.PATH || "" - return withoutInternalServerAuthEnv({ + // Read PATH case-insensitively: a shell plugin may emit "Path" on + // Windows, and process.env preserves the OS casing during spread. + // After the merge, strip every case-variant of PATH before writing + // back a single canonical PATH so the spawned child does not receive + // both `Path` and `PATH` (the latter would otherwise win and drop + // the inherited system path). + const currentPath = + envValueCaseInsensitive(extraEnv, "PATH") ?? envValueCaseInsensitive(process.env, "PATH") ?? "" + const env = withoutInternalServerAuthEnv({ ...process.env, ...extraEnv, OFFICECLI_SKIP_UPDATE: "1", - PATH: bundledToolsDir ? `${bundledToolsDir}${path.delimiter}${currentPath}` : currentPath, - }) + } as Record) + stripPathKeys(env) + env.PATH = prependBundledTools(currentPath) + return env }) const readTrackedState = Effect.fn("BashTool.readTrackedState")((file: string) => diff --git a/packages/opencode/src/util/env.ts b/packages/opencode/src/util/env.ts index 456d9eb3d..cd3e76e1d 100644 --- a/packages/opencode/src/util/env.ts +++ b/packages/opencode/src/util/env.ts @@ -1,3 +1,5 @@ +import path from "path" + const INTERNAL_SERVER_AUTH_ENV = new Set(["opencode_server_password", "opencode_server_username"]) export function withoutInternalServerAuthEnv>(env: T): T { @@ -12,3 +14,36 @@ export function envValueCaseInsensitive(env: Record const normalized = name.toLowerCase() return Object.entries(env ?? {}).find(([key]) => key.toLowerCase() === normalized)?.[1] } + +// Returns the directory holding PawWork's bundled CLI tools (officecli, ...), +// or "" when not running inside the packaged Electron app (e.g. plain `bun dev`). +// In dev:desktop, process.resourcesPath points to the Electron framework's +// Resources, not PawWork's — there's no tools/ subdir there, so the prepend +// is a no-op (the directory simply doesn't exist on disk). +export function bundledToolsDir(): string { + const resourcesPath = (process as unknown as { resourcesPath?: string }).resourcesPath + return resourcesPath ? path.join(resourcesPath, "tools") : "" +} + +// Prepends bundledToolsDir to a PATH string so child processes can resolve +// PawWork's bundled CLIs (e.g. `officecli`) by bare name. Pass the PATH that +// will end up in the spawned env; pass "" if unknown. +export function prependBundledTools(currentPath: string): string { + const dir = bundledToolsDir() + if (!dir) return currentPath + // Don't append a trailing delimiter when currentPath is empty: on POSIX an + // empty PATH segment is interpreted as the current directory, which weakens + // command-resolution safety (cwd-shadowing of system commands). + return currentPath ? `${dir}${path.delimiter}${currentPath}` : dir +} + +// Removes every case-variant of the PATH key from an env record in place. +// Use before writing back a canonical `PATH` to a merged env, otherwise on +// Windows the result can carry both `Path` (inherited from process.env) and +// `PATH` (added explicitly); spawn then forwards both to the child with +// implementation-defined precedence. +export function stripPathKeys(env: Record): void { + for (const key of Object.keys(env)) { + if (key.toLowerCase() === "path") delete env[key] + } +} diff --git a/packages/opencode/test/config/e2e-smoke-tagging.test.ts b/packages/opencode/test/config/e2e-smoke-tagging.test.ts index 68a7e114a..01d8302fb 100644 --- a/packages/opencode/test/config/e2e-smoke-tagging.test.ts +++ b/packages/opencode/test/config/e2e-smoke-tagging.test.ts @@ -14,6 +14,10 @@ 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", + "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/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", diff --git a/packages/opencode/test/util/env.test.ts b/packages/opencode/test/util/env.test.ts index d0b43db60..4ab05e4fc 100644 --- a/packages/opencode/test/util/env.test.ts +++ b/packages/opencode/test/util/env.test.ts @@ -1,5 +1,18 @@ -import { describe, expect, test } from "bun:test" -import { withoutInternalServerAuthEnv } from "../../src/util/env" +import { afterEach, describe, expect, test } from "bun:test" +import path from "path" +import { bundledToolsDir, prependBundledTools, stripPathKeys, withoutInternalServerAuthEnv } from "../../src/util/env" + +type ResourcesPathBag = { resourcesPath?: string } + +function setResourcesPath(value: string | undefined) { + const bag = process as unknown as ResourcesPathBag + if (value === undefined) delete bag.resourcesPath + else bag.resourcesPath = value +} + +function getResourcesPath(): string | undefined { + return (process as unknown as ResourcesPathBag).resourcesPath +} describe("util.env", () => { test("does not mutate caller-owned env objects", () => { @@ -38,3 +51,62 @@ describe("util.env", () => { expect(sanitized).not.toBe(env) }) }) + +describe("util.env.bundledTools", () => { + const original = getResourcesPath() + afterEach(() => setResourcesPath(original)) + + test("returns empty string when resourcesPath is unset (e.g. plain node/bun)", () => { + setResourcesPath(undefined) + expect(bundledToolsDir()).toBe("") + expect(prependBundledTools("/usr/bin")).toBe("/usr/bin") + }) + + test("treats empty resourcesPath as unset so PATH is not poisoned with a relative 'tools'", () => { + // path.join("", "tools") returns the relative string "tools"; if that + // leaked into PATH, the shell would resolve `tools` against cwd. Guard. + setResourcesPath("") + expect(bundledToolsDir()).toBe("") + expect(prependBundledTools("/usr/bin")).toBe("/usr/bin") + }) + + test("prepends bundled tools dir to PATH, preserving the rest", () => { + setResourcesPath("/Applications/PawWork.app/Contents/Resources") + const expectedDir = path.join("/Applications/PawWork.app/Contents/Resources", "tools") + expect(bundledToolsDir()).toBe(expectedDir) + expect(prependBundledTools("/usr/bin:/bin")).toBe(`${expectedDir}${path.delimiter}/usr/bin:/bin`) + }) + + test("prepend with empty currentPath returns bundled dir alone, never a trailing-delimiter PATH (cwd-shadowing guard)", () => { + // POSIX treats an empty PATH segment (leading/trailing/double colon) as + // the current directory, so emitting "/r/tools:" would let a malicious + // file in cwd shadow officecli. The helper must drop the delimiter. + setResourcesPath("/r") + expect(prependBundledTools("")).toBe(path.join("/r", "tools")) + }) +}) + +describe("util.env.stripPathKeys", () => { + test("removes every case-variant of PATH while leaving the rest untouched", () => { + // Windows ships `Path`, some shells emit `path`, our code adds `PATH`. + // After spreading process.env into a child env all three can co-exist, + // and spawn forwards them with implementation-defined precedence. + const env: Record = { + Path: "/system/path", + PATH: "/our/override", + path: "/lowercase", + TERM: "xterm", + OFFICECLI_SKIP_UPDATE: "1", + } + + stripPathKeys(env) + + expect(env).toEqual({ TERM: "xterm", OFFICECLI_SKIP_UPDATE: "1" }) + }) + + test("is safe on an env that has no path keys at all", () => { + const env: Record = { TERM: "xterm" } + stripPathKeys(env) + expect(env).toEqual({ TERM: "xterm" }) + }) +})