diff --git a/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx b/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx index 81d0f1978..ec4915ca6 100644 --- a/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx +++ b/apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx @@ -19,6 +19,7 @@ import { toast } from "sonner" import { ScrollArea } from "@/components/ui/scroll-area" import { cn } from "@/lib/utils" +import { EmptyStateSuggestions } from "@/components/app-shell/EmptyStateSuggestions" import { Markdown, CollapsibleMarkdownProvider, StreamingMarkdown, type RenderMode } from "@/components/markdown" import { AnimatedCollapsibleContent } from "@/components/ui/collapsible" import { @@ -1698,6 +1699,16 @@ export const ChatDisplay = React.forwardRef {t('chat.emptyTitle')} {renderChatInputZone('mt-0 px-0 pb-0 @xs/panel:px-0')} + {(inputValue ?? '').trim().length === 0 ? ( + { + onInputChange?.(prompt) + // Let the controlled value propagate to the composer, then focus. + requestAnimationFrame(() => textareaRef.current?.focus()) + }} + /> + ) : null} ) : null} diff --git a/apps/electron/src/renderer/components/app-shell/EmptyStateSuggestions.tsx b/apps/electron/src/renderer/components/app-shell/EmptyStateSuggestions.tsx new file mode 100644 index 000000000..998e21a6f --- /dev/null +++ b/apps/electron/src/renderer/components/app-shell/EmptyStateSuggestions.tsx @@ -0,0 +1,80 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { BookOpen, Hammer, Bug, FlaskConical, type LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +/** + * Starter prompt suggestions shown on the empty conversation state, beneath the + * centered composer. Mirrors the "prompt suggestions" surface in Claude Code + * Desktop / ChatGPT / Codex: a few clickable starting points that populate the + * composer (they do NOT auto-send, so the user can edit before submitting). + * + * Each suggestion pairs a short, localized label with a fuller prompt. Clicking + * one calls `onSelect(prompt)` — the parent seeds the (controlled) composer via + * the same draft channel used elsewhere, then focuses the input. + */ + +interface SuggestionDef { + /** Stable id → i18n keys `chat.suggestions..title` / `.prompt`. */ + id: string + icon: LucideIcon +} + +/** The fixed set of starter suggestions. Order here is the render order. */ +const SUGGESTIONS: readonly SuggestionDef[] = [ + { id: 'explain', icon: BookOpen }, + { id: 'build', icon: Hammer }, + { id: 'fix', icon: Bug }, + { id: 'tests', icon: FlaskConical }, +] as const + +export interface EmptyStateSuggestionsProps { + /** Called with the full prompt text when a suggestion is chosen. */ + onSelect: (prompt: string) => void + /** Hide the surface entirely (e.g. no connection / input disabled). */ + disabled?: boolean + className?: string +} + +export function EmptyStateSuggestions({ + onSelect, + disabled = false, + className, +}: EmptyStateSuggestionsProps) { + const { t } = useTranslation() + + if (disabled) return null + + return ( +
+ {SUGGESTIONS.map(({ id, icon: Icon }) => { + const title = t(`chat.suggestions.${id}.title`) + const prompt = t(`chat.suggestions.${id}.prompt`) + return ( + + ) + })} +
+ ) +} diff --git a/docs/loop/feature-ledger.md b/docs/loop/feature-ledger.md index 404fb476b..7b9c1332d 100644 --- a/docs/loop/feature-ledger.md +++ b/docs/loop/feature-ledger.md @@ -33,6 +33,7 @@ log, not the system of record. | slug | title | source | feasibility | status | issue | pr | branch | updated | notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| starter-suggestions | Starter prompt suggestions on the empty conversation | Claude Code Desktop "prompt suggestions" / ChatGPT & Codex example prompts | frontend-only | pr-open | [#72](https://github.com/modelstudioai/openwork/issues/72) | [#73](https://github.com/modelstudioai/openwork/pull/73) | loop/starter-suggestions | 2026-07-09 | New `EmptyStateSuggestions` chip row rendered under the centered empty-state composer in `ChatDisplay` (only while composer is empty). Clicking a chip seeds the composer via the existing controlled draft channel (`onInputChange`) + focuses it — does NOT auto-send. 4 suggestions, 8 new i18n keys ×7 locales. typecheck:all / `bun test` zero-delta vs main (11 electron-typecheck + 56-test pre-existing baseline byte-identical); renderer build ✅; i18n parity ✅; lint 0 errors on touched files. CDP assertion `e2e/assertions/starter-suggestions.assert.ts` included; **could not execute locally** (Electron binary egress-blocked: `github.com` releases 403). | | scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the chat transcript | Claude Code Desktop / ChatGPT / Codex desktop jump-to-latest affordance | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-02 | Reuses existing `ChatDisplay` scroll state (`distanceFromBottom`, `isStickToBottomRef`, `messagesEndRef`); floating `AnimatePresence` button shown when >200px from bottom. One new i18n key `chat.scrollToBottom` across all 6 locales. Added a reusable `seed(profileDirs)` hook to the e2e harness (`app.ts`/`runner.ts`) so assertions can pre-seed an on-disk session (backend-independent) — the scroll assertion seeds a 40-message session, opens it, scrolls up, asserts the button appears, clicks it, asserts return-to-bottom + hide. typecheck/`bun test` zero-delta vs main (11 pre-existing tsc errors, 56 pre-existing test fails, identical sets); renderer build ✅. **CDP could not run locally**: the Electron binary download is 403'd by the sandbox egress policy (github release host), so the app can't launch here — assertion transpiles and is included for CI/reviewer. | | thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-02 | Merged into `main`. `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). typecheck/`bun test` zero-delta vs main. | | composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude/ChatGPT/Codex desktop composer maximize | frontend-only | pr-open | [#48](https://github.com/modelstudioai/openwork/issues/48) | [#49](https://github.com/modelstudioai/openwork/pull/49) | loop/composer-expand | 2026-07-03 | Opened by a prior run. Adds `isComposerExpanded` toggle in `FreeFormInput`; 2 new i18n keys. Awaiting review. | diff --git a/e2e/assertions/starter-suggestions.assert.ts b/e2e/assertions/starter-suggestions.assert.ts new file mode 100644 index 000000000..329ea8b4a --- /dev/null +++ b/e2e/assertions/starter-suggestions.assert.ts @@ -0,0 +1,107 @@ +/** + * Feature assertion: starter prompt suggestions on the empty conversation state. + * + * Drives the real built app over CDP through the full path: + * boot into the empty draft chat → the suggestion chips render → click one → + * the composer is populated with that suggestion's (fuller) prompt → the + * suggestions surface disappears once the composer has content. + * + * This proves the feature actually *does* something (seeds the composer), not + * merely that the chips render. + */ + +import type { Assertion } from '../runner'; + +const SUGGESTIONS = '[data-testid="empty-suggestions"]'; +const CHIP = '[data-testid="empty-suggestion"]'; +const COMPOSER = '[role="textbox"][aria-multiline="true"]'; + +/** Visible chips only (defensive against hidden/duplicated nodes). */ +const VISIBLE_CHIPS_EXPR = `[...document.querySelectorAll(${JSON.stringify( + CHIP, +)})].filter((el) => el.offsetParent !== null)`; + +/** Trimmed text of the (visible) composer, with zero-width chars stripped. */ +const COMPOSER_TEXT_EXPR = `(() => { + const el = [...document.querySelectorAll(${JSON.stringify( + COMPOSER, + )})].find((n) => n.offsetParent !== null); + if (!el) return null; + return (el.textContent || '').replace(/[\\u200B-\\u200D\\uFEFF]/g, '').trim(); +})()`; + +const assertion: Assertion = { + name: 'empty-state starter suggestions seed the composer', + async run(app) { + const { session } = app; + + // App fully mounted. + await session.waitForFunction( + '!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0', + { timeoutMs: 30000, message: 'React UI did not mount' }, + ); + + // Reach the ready AppShell (not onboarding / workspace-picker). The empty + // draft chat — with its centered composer — is the default landing view. + await session.waitForSelector('[aria-label="Craft menu"]', { + timeoutMs: 30000, + message: 'app did not reach the ready AppShell state', + }); + + // 1. The suggestions surface renders on the empty conversation. + await session.waitForSelector(SUGGESTIONS, { + timeoutMs: 15000, + message: 'starter suggestions did not render on the empty conversation', + }); + + // 2. It shows the full set of chips (4). + await session.waitForFunction(`${VISIBLE_CHIPS_EXPR}.length === 4`, { + timeoutMs: 8000, + message: 'expected 4 visible starter-suggestion chips', + }); + + // 3. The composer starts empty. + const before = await session.evaluate(COMPOSER_TEXT_EXPR); + if (before == null) throw new Error('could not locate the composer text box'); + if (before.length !== 0) { + throw new Error(`composer was not empty at start (saw: ${JSON.stringify(before)})`); + } + + // 4. Capture the first chip's visible label, then click it. + const label = await session.evaluate( + `(() => { const el = ${VISIBLE_CHIPS_EXPR}[0]; return el ? (el.textContent || '').trim() : null; })()`, + ); + if (!label) throw new Error('could not read the first suggestion label'); + + const clicked = await session.evaluate( + `(() => { const el = ${VISIBLE_CHIPS_EXPR}[0]; if (!el) return false; el.click(); return true; })()`, + ); + if (!clicked) throw new Error('failed to click the first suggestion chip'); + + // 5. The composer is populated with the suggestion's prompt. The prompt is a + // full sentence, so it must be non-empty and longer than the short label — + // proving it seeded the *prompt*, not merely echoed the chip title. + await session.waitForFunction( + `(() => { + const text = ${COMPOSER_TEXT_EXPR}; + return typeof text === 'string' && text.length > ${label.length} && text.length > 20; + })()`, + { + timeoutMs: 8000, + message: 'clicking a suggestion did not populate the composer with its prompt', + }, + ); + + // 6. Once the composer has content, the suggestions surface goes away + // (matching the empty-state-only behavior of the feature). + await session.waitForFunction( + `!document.querySelector(${JSON.stringify(SUGGESTIONS)})`, + { + timeoutMs: 8000, + message: 'suggestions did not hide after the composer was populated', + }, + ); + }, +}; + +export default assertion; diff --git a/packages/shared/src/i18n/locales/de.json b/packages/shared/src/i18n/locales/de.json index 1d48afa80..e400f7736 100644 --- a/packages/shared/src/i18n/locales/de.json +++ b/packages/shared/src/i18n/locales/de.json @@ -141,6 +141,14 @@ "chat.contextUsage.title": "Kontextfenster", "chat.contextUsage.usedOfTotal": "{{used}} verwendet, {{total}} gesamt", "chat.emptyTitle": "Was sollen wir bauen?", + "chat.suggestions.explain.title": "Diese Codebasis erklären", + "chat.suggestions.explain.prompt": "Gib mir einen Überblick über diese Codebasis – ihren Zweck, die wichtigsten Komponenten und wie die Teile zusammenspielen.", + "chat.suggestions.build.title": "Ein Feature bauen", + "chat.suggestions.build.prompt": "Hilf mir, ein neues Feature zu bauen. Frag mich, was es können soll, schlage dann einen Ansatz vor und setze ihn um.", + "chat.suggestions.fix.title": "Einen Bug beheben", + "chat.suggestions.fix.prompt": "Hilf mir, einen Bug zu finden und zu beheben. Ich beschreibe das Problem und du untersuchst den relevanten Code.", + "chat.suggestions.tests.title": "Tests schreiben", + "chat.suggestions.tests.prompt": "Schreibe Tests für einen Teil meines Projekts. Sag mir, was abzudecken ist, und füge gründliche, bestehende Tests hinzu.", "chat.enterSessionName": "Sitzungsname eingeben...", "chat.failedToStopSharing": "Freigabe konnte nicht beendet werden", "chat.failedToUpdateShare": "Freigabe konnte nicht aktualisiert werden", diff --git a/packages/shared/src/i18n/locales/en.json b/packages/shared/src/i18n/locales/en.json index 9935f62d5..6ce067873 100644 --- a/packages/shared/src/i18n/locales/en.json +++ b/packages/shared/src/i18n/locales/en.json @@ -141,6 +141,14 @@ "chat.contextUsage.title": "Context window", "chat.contextUsage.usedOfTotal": "{{used}} used, {{total}} total", "chat.emptyTitle": "What should we build?", + "chat.suggestions.explain.title": "Explain this codebase", + "chat.suggestions.explain.prompt": "Give me a high-level overview of this codebase — its purpose, main components, and how the pieces fit together.", + "chat.suggestions.build.title": "Build a feature", + "chat.suggestions.build.prompt": "Help me build a new feature. Ask me what it should do, then propose an approach and implement it.", + "chat.suggestions.fix.title": "Fix a bug", + "chat.suggestions.fix.prompt": "Help me track down and fix a bug. I'll describe what's going wrong and you investigate the relevant code.", + "chat.suggestions.tests.title": "Write tests", + "chat.suggestions.tests.prompt": "Write tests for a part of my project. Point me at what to cover and add thorough, passing tests.", "chat.enterSessionName": "Enter session name...", "chat.failedToStopSharing": "Failed to stop sharing", "chat.failedToUpdateShare": "Failed to update share", diff --git a/packages/shared/src/i18n/locales/es.json b/packages/shared/src/i18n/locales/es.json index 1fdba2fa7..369f9306c 100644 --- a/packages/shared/src/i18n/locales/es.json +++ b/packages/shared/src/i18n/locales/es.json @@ -141,6 +141,14 @@ "chat.contextUsage.title": "Ventana de contexto", "chat.contextUsage.usedOfTotal": "{{used}} usados, {{total}} en total", "chat.emptyTitle": "¿Qué deberíamos construir?", + "chat.suggestions.explain.title": "Explicar este código", + "chat.suggestions.explain.prompt": "Dame una visión general de este código: su propósito, sus componentes principales y cómo encajan las piezas.", + "chat.suggestions.build.title": "Crear una función", + "chat.suggestions.build.prompt": "Ayúdame a crear una nueva función. Pregúntame qué debe hacer, propón un enfoque e impleméntalo.", + "chat.suggestions.fix.title": "Corregir un error", + "chat.suggestions.fix.prompt": "Ayúdame a localizar y corregir un error. Yo describo qué falla y tú investigas el código relevante.", + "chat.suggestions.tests.title": "Escribir pruebas", + "chat.suggestions.tests.prompt": "Escribe pruebas para una parte de mi proyecto. Indícame qué cubrir y añade pruebas completas que pasen.", "chat.enterSessionName": "Introduce el nombre de la sesión...", "chat.failedToStopSharing": "Error al detener la compartición", "chat.failedToUpdateShare": "Error al actualizar el compartido", diff --git a/packages/shared/src/i18n/locales/hu.json b/packages/shared/src/i18n/locales/hu.json index 465aa79a7..17aa14216 100644 --- a/packages/shared/src/i18n/locales/hu.json +++ b/packages/shared/src/i18n/locales/hu.json @@ -141,6 +141,14 @@ "chat.contextUsage.title": "Kontextusablak", "chat.contextUsage.usedOfTotal": "{{used}} felhasználva, összesen {{total}}", "chat.emptyTitle": "Mit építsünk?", + "chat.suggestions.explain.title": "Kódbázis bemutatása", + "chat.suggestions.explain.prompt": "Adj áttekintést erről a kódbázisról – a céljáról, a fő komponenseiről, és arról, hogyan illeszkednek össze a részek.", + "chat.suggestions.build.title": "Funkció létrehozása", + "chat.suggestions.build.prompt": "Segíts egy új funkció elkészítésében. Kérdezd meg, mit kell csinálnia, majd javasolj megközelítést és valósítsd meg.", + "chat.suggestions.fix.title": "Hiba javítása", + "chat.suggestions.fix.prompt": "Segíts megtalálni és kijavítani egy hibát. Leírom, mi a probléma, te pedig vizsgáld meg az érintett kódot.", + "chat.suggestions.tests.title": "Tesztek írása", + "chat.suggestions.tests.prompt": "Írj teszteket a projektem egy részéhez. Mondd meg, mit fedjek le, és adj hozzá alapos, sikeresen lefutó teszteket.", "chat.enterSessionName": "Add meg a munkamenet nevét...", "chat.failedToStopSharing": "A megosztás leállítása sikertelen", "chat.failedToUpdateShare": "A megosztás frissítése sikertelen", diff --git a/packages/shared/src/i18n/locales/ja.json b/packages/shared/src/i18n/locales/ja.json index c859d5ef6..ca8da03e2 100644 --- a/packages/shared/src/i18n/locales/ja.json +++ b/packages/shared/src/i18n/locales/ja.json @@ -141,6 +141,14 @@ "chat.contextUsage.title": "コンテキストウィンドウ", "chat.contextUsage.usedOfTotal": "{{used}} 使用済み、合計 {{total}}", "chat.emptyTitle": "何を構築しましょうか?", + "chat.suggestions.explain.title": "このコードベースを解説する", + "chat.suggestions.explain.prompt": "このコードベースの概要を教えてください。目的、主要なコンポーネント、そして各部分がどう組み合わさっているかを説明してください。", + "chat.suggestions.build.title": "機能を作る", + "chat.suggestions.build.prompt": "新しい機能の実装を手伝ってください。まず何をするべきか確認し、方針を提案してから実装してください。", + "chat.suggestions.fix.title": "バグを修正する", + "chat.suggestions.fix.prompt": "バグの特定と修正を手伝ってください。症状を説明するので、関連するコードを調査してください。", + "chat.suggestions.tests.title": "テストを書く", + "chat.suggestions.tests.prompt": "プロジェクトの一部にテストを追加してください。カバーすべき範囲を伝えるので、網羅的でパスするテストを書いてください。", "chat.enterSessionName": "セッション名を入力...", "chat.failedToStopSharing": "共有の停止に失敗しました", "chat.failedToUpdateShare": "共有の更新に失敗しました", diff --git a/packages/shared/src/i18n/locales/pl.json b/packages/shared/src/i18n/locales/pl.json index 205d92aa6..ac216ecf0 100644 --- a/packages/shared/src/i18n/locales/pl.json +++ b/packages/shared/src/i18n/locales/pl.json @@ -141,6 +141,14 @@ "chat.contextUsage.title": "Okno kontekstu", "chat.contextUsage.usedOfTotal": "Użyto {{used}}, łącznie {{total}}", "chat.emptyTitle": "Co powinniśmy zbudować?", + "chat.suggestions.explain.title": "Wyjaśnij tę bazę kodu", + "chat.suggestions.explain.prompt": "Przedstaw mi ogólny obraz tej bazy kodu — jej cel, główne komponenty i to, jak elementy do siebie pasują.", + "chat.suggestions.build.title": "Zbuduj funkcję", + "chat.suggestions.build.prompt": "Pomóż mi zbudować nową funkcję. Zapytaj, co ma robić, a następnie zaproponuj podejście i je wdróż.", + "chat.suggestions.fix.title": "Napraw błąd", + "chat.suggestions.fix.prompt": "Pomóż mi znaleźć i naprawić błąd. Opiszę, co jest nie tak, a ty zbadaj odpowiedni kod.", + "chat.suggestions.tests.title": "Napisz testy", + "chat.suggestions.tests.prompt": "Napisz testy dla części mojego projektu. Wskaż, co pokryć, i dodaj dokładne, przechodzące testy.", "chat.enterSessionName": "Wprowadź nazwę sesji...", "chat.failedToStopSharing": "Nie udało się zatrzymać udostępniania", "chat.failedToUpdateShare": "Nie udało się zaktualizować udostępnienia", diff --git a/packages/shared/src/i18n/locales/zh-Hans.json b/packages/shared/src/i18n/locales/zh-Hans.json index 859f25c33..564c407f7 100644 --- a/packages/shared/src/i18n/locales/zh-Hans.json +++ b/packages/shared/src/i18n/locales/zh-Hans.json @@ -141,6 +141,14 @@ "chat.contextUsage.title": "上下文窗口", "chat.contextUsage.usedOfTotal": "已用 {{used}},共 {{total}}", "chat.emptyTitle": "我们该构建什么", + "chat.suggestions.explain.title": "解读这个代码库", + "chat.suggestions.explain.prompt": "请从整体上介绍这个代码库——它的用途、主要组成部分,以及各部分如何协同工作。", + "chat.suggestions.build.title": "构建一个功能", + "chat.suggestions.build.prompt": "帮我构建一个新功能。先问清楚它需要做什么,然后提出方案并实现它。", + "chat.suggestions.fix.title": "修复一个 Bug", + "chat.suggestions.fix.prompt": "帮我定位并修复一个 Bug。我来描述出现的问题,你负责排查相关代码。", + "chat.suggestions.tests.title": "编写测试", + "chat.suggestions.tests.prompt": "为我项目的某个部分编写测试。告诉我需要覆盖的范围,并补充完整且能通过的测试。", "chat.enterSessionName": "输入会话名称...", "chat.failedToStopSharing": "无法停止共享", "chat.failedToUpdateShare": "无法更新共享",