Skip to content

Commit 457f6b9

Browse files
DragonnZhangclaude
andauthored
Add starter prompt suggestions to the empty conversation state (#73)
Render a row of clickable starter-prompt chips beneath the centered composer on the empty conversation state (only while the composer is empty). Clicking a chip seeds the composer with a fuller prompt via the existing controlled-draft channel and focuses the input — it does not auto-send, so the user can edit before submitting. - New EmptyStateSuggestions component (4 build-oriented suggestions) - Wired into ChatDisplay's empty-state block; hides once the composer has content - 8 new i18n keys across all 7 locales - CDP assertion driving the full path (render -> click -> composer seeded -> suggestions hide) Closes #72 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8abfafe commit 457f6b9

11 files changed

Lines changed: 255 additions & 0 deletions

File tree

apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { toast } from "sonner"
1919

2020
import { ScrollArea } from "@/components/ui/scroll-area"
2121
import { cn } from "@/lib/utils"
22+
import { EmptyStateSuggestions } from "@/components/app-shell/EmptyStateSuggestions"
2223
import { Markdown, CollapsibleMarkdownProvider, StreamingMarkdown, type RenderMode } from "@/components/markdown"
2324
import { AnimatedCollapsibleContent } from "@/components/ui/collapsible"
2425
import {
@@ -1698,6 +1699,16 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
16981699
{t('chat.emptyTitle')}
16991700
</h1>
17001701
{renderChatInputZone('mt-0 px-0 pb-0 @xs/panel:px-0')}
1702+
{(inputValue ?? '').trim().length === 0 ? (
1703+
<EmptyStateSuggestions
1704+
disabled={isInputDisabled || disableSend || connectionUnavailable}
1705+
onSelect={(prompt) => {
1706+
onInputChange?.(prompt)
1707+
// Let the controlled value propagate to the composer, then focus.
1708+
requestAnimationFrame(() => textareaRef.current?.focus())
1709+
}}
1710+
/>
1711+
) : null}
17011712
</motion.div>
17021713
</div>
17031714
) : null}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import * as React from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import { BookOpen, Hammer, Bug, FlaskConical, type LucideIcon } from 'lucide-react'
4+
import { cn } from '@/lib/utils'
5+
6+
/**
7+
* Starter prompt suggestions shown on the empty conversation state, beneath the
8+
* centered composer. Mirrors the "prompt suggestions" surface in Claude Code
9+
* Desktop / ChatGPT / Codex: a few clickable starting points that populate the
10+
* composer (they do NOT auto-send, so the user can edit before submitting).
11+
*
12+
* Each suggestion pairs a short, localized label with a fuller prompt. Clicking
13+
* one calls `onSelect(prompt)` — the parent seeds the (controlled) composer via
14+
* the same draft channel used elsewhere, then focuses the input.
15+
*/
16+
17+
interface SuggestionDef {
18+
/** Stable id → i18n keys `chat.suggestions.<id>.title` / `.prompt`. */
19+
id: string
20+
icon: LucideIcon
21+
}
22+
23+
/** The fixed set of starter suggestions. Order here is the render order. */
24+
const SUGGESTIONS: readonly SuggestionDef[] = [
25+
{ id: 'explain', icon: BookOpen },
26+
{ id: 'build', icon: Hammer },
27+
{ id: 'fix', icon: Bug },
28+
{ id: 'tests', icon: FlaskConical },
29+
] as const
30+
31+
export interface EmptyStateSuggestionsProps {
32+
/** Called with the full prompt text when a suggestion is chosen. */
33+
onSelect: (prompt: string) => void
34+
/** Hide the surface entirely (e.g. no connection / input disabled). */
35+
disabled?: boolean
36+
className?: string
37+
}
38+
39+
export function EmptyStateSuggestions({
40+
onSelect,
41+
disabled = false,
42+
className,
43+
}: EmptyStateSuggestionsProps) {
44+
const { t } = useTranslation()
45+
46+
if (disabled) return null
47+
48+
return (
49+
<div
50+
data-testid="empty-suggestions"
51+
className={cn(
52+
'mx-auto mt-4 grid w-full max-w-[840px] grid-cols-1 gap-2 sm:grid-cols-2',
53+
className,
54+
)}
55+
>
56+
{SUGGESTIONS.map(({ id, icon: Icon }) => {
57+
const title = t(`chat.suggestions.${id}.title`)
58+
const prompt = t(`chat.suggestions.${id}.prompt`)
59+
return (
60+
<button
61+
key={id}
62+
type="button"
63+
data-testid="empty-suggestion"
64+
data-suggestion-id={id}
65+
onClick={() => onSelect(prompt)}
66+
className={cn(
67+
'group flex items-center gap-2.5 rounded-[10px] border bg-muted/20 px-3.5 py-2.5 text-left',
68+
'text-sm text-foreground/80 transition-colors',
69+
'hover:bg-muted/50 hover:text-foreground',
70+
'focus:outline-none focus-visible:ring-1 focus-visible:ring-ring',
71+
)}
72+
>
73+
<Icon className="h-4 w-4 shrink-0 text-muted-foreground group-hover:text-foreground" />
74+
<span className="min-w-0 truncate font-medium">{title}</span>
75+
</button>
76+
)
77+
})}
78+
</div>
79+
)
80+
}

docs/loop/feature-ledger.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ log, not the system of record.
3333

3434
| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
3535
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
36+
| 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). |
3637
| interface-zoom | Interface zoom (⌘+/⌘-/⌘0) to scale the whole app | Claude Desktop / VS Code / Codex desktop View→Zoom In/Out/Actual Size | frontend-only | pr-open | [#68](https://github.com/modelstudioai/openwork/issues/68) | [#69](https://github.com/modelstudioai/openwork/pull/69) | loop/interface-zoom | 2026-07-08 | New `ZoomProvider` (mirrors `ReduceMotionProvider`) scales whole renderer via `document.documentElement.style.zoom`, discrete 50–200% steps, persisted in localStorage (`craft-zoom-level`). 3 View actions `view.zoomIn/Out/Reset` (`mod+=`/`mod+-`/`mod+0`) → auto in Command Palette + Shortcuts ref, wired via `ZoomHotkeys` bridge. Stepper in Appearance→Interface. 5 i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; lint 0 errors; i18n parity ✅ (1549 keys). CDP assertion included; **could not run locally** (org egress 403 on Electron binary download — same block as #51). Distinct from chat-text-size (#64)/conversation-width (#62): scales entire UI, not just chat text. |
3738
| high-contrast | "Increase contrast" accessibility setting in Appearance | macOS Accessibility "Increase contrast" / Windows contrast themes / VS Code High Contrast | frontend-only | pr-open | [#66](https://github.com/modelstudioai/openwork/issues/66) | [#67](https://github.com/modelstudioai/openwork/pull/67) | loop/high-contrast | 2026-07-07 | Companion to merged reduce-motion. Renderer-only pref (`craft-high-contrast` localStorage) → `HighContrastProvider` context → `data-high-contrast` on `<html>` → CSS overrides of theme tokens (`--border`/`--input`/`--muted-foreground`/`--ring`) gated on `:root[data-high-contrast='true']` (higher specificity beats theme `:root` injection; works light+dark since tokens derive from `--foreground`). Toggle in Appearance→Interface below Reduce motion; 2 i18n keys ×7 locales. typecheck:all/`bun test` zero-delta vs main (11 electron / 56 test pre-existing failures byte-identical); i18n parity OK; renderer build ✅ (verified rule in bundled CSS). CDP assertion written (`high-contrast.assert.ts`); **not run locally** — egress 403 blocks Electron binary download (same env block as #51). |
3839
| chat-text-size | "Chat text size" setting (Small / Default / Large) in Appearance | Claude Desktop "Chat font" + anthropics/claude-code #50543/#48887; ChatGPT desktop font-size requests | frontend-only | pr-open | [#64](https://github.com/modelstudioai/openwork/issues/64) | [#65](https://github.com/modelstudioai/openwork/pull/65) | loop/chat-text-size | 2026-07-07 | Renderer-only pref (localStorage `craft-chat-text-size`) reflected onto `<html>` as `data-chat-text-size` + `--chat-font-scale` CSS var (0.9/1/1.15). Transcript container gets `.chat-text-scope` with `font-size: calc(1em * var(--chat-font-scale))` — em-relative ⇒ neutral at Default, scales only conversation text (chrome untouched). New `ChatTextSizeProvider`; segmented control in Appearance→Interface; `SettingsSegmentedControl` gained optional `testId`/`data-value`; 5 new i18n keys ×7 locales. typecheck/`bun test` **zero-delta vs main** (11 pre-existing tsc errors + 56 pre-existing fail-lines byte-identical); renderer build ✅; i18n parity ✅. CDP assertion authored (drives control, asserts attr/CSS-var/localStorage + probe computed font-size ratio ~1.15/0.9); **could not execute** — Electron runtime binary download is org-egress-policy 403 (same block as #51). |
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* Feature assertion: starter prompt suggestions on the empty conversation state.
3+
*
4+
* Drives the real built app over CDP through the full path:
5+
* boot into the empty draft chat → the suggestion chips render → click one →
6+
* the composer is populated with that suggestion's (fuller) prompt → the
7+
* suggestions surface disappears once the composer has content.
8+
*
9+
* This proves the feature actually *does* something (seeds the composer), not
10+
* merely that the chips render.
11+
*/
12+
13+
import type { Assertion } from '../runner';
14+
15+
const SUGGESTIONS = '[data-testid="empty-suggestions"]';
16+
const CHIP = '[data-testid="empty-suggestion"]';
17+
const COMPOSER = '[role="textbox"][aria-multiline="true"]';
18+
19+
/** Visible chips only (defensive against hidden/duplicated nodes). */
20+
const VISIBLE_CHIPS_EXPR = `[...document.querySelectorAll(${JSON.stringify(
21+
CHIP,
22+
)})].filter((el) => el.offsetParent !== null)`;
23+
24+
/** Trimmed text of the (visible) composer, with zero-width chars stripped. */
25+
const COMPOSER_TEXT_EXPR = `(() => {
26+
const el = [...document.querySelectorAll(${JSON.stringify(
27+
COMPOSER,
28+
)})].find((n) => n.offsetParent !== null);
29+
if (!el) return null;
30+
return (el.textContent || '').replace(/[\\u200B-\\u200D\\uFEFF]/g, '').trim();
31+
})()`;
32+
33+
const assertion: Assertion = {
34+
name: 'empty-state starter suggestions seed the composer',
35+
async run(app) {
36+
const { session } = app;
37+
38+
// App fully mounted.
39+
await session.waitForFunction(
40+
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
41+
{ timeoutMs: 30000, message: 'React UI did not mount' },
42+
);
43+
44+
// Reach the ready AppShell (not onboarding / workspace-picker). The empty
45+
// draft chat — with its centered composer — is the default landing view.
46+
await session.waitForSelector('[aria-label="Craft menu"]', {
47+
timeoutMs: 30000,
48+
message: 'app did not reach the ready AppShell state',
49+
});
50+
51+
// 1. The suggestions surface renders on the empty conversation.
52+
await session.waitForSelector(SUGGESTIONS, {
53+
timeoutMs: 15000,
54+
message: 'starter suggestions did not render on the empty conversation',
55+
});
56+
57+
// 2. It shows the full set of chips (4).
58+
await session.waitForFunction(`${VISIBLE_CHIPS_EXPR}.length === 4`, {
59+
timeoutMs: 8000,
60+
message: 'expected 4 visible starter-suggestion chips',
61+
});
62+
63+
// 3. The composer starts empty.
64+
const before = await session.evaluate<string | null>(COMPOSER_TEXT_EXPR);
65+
if (before == null) throw new Error('could not locate the composer text box');
66+
if (before.length !== 0) {
67+
throw new Error(`composer was not empty at start (saw: ${JSON.stringify(before)})`);
68+
}
69+
70+
// 4. Capture the first chip's visible label, then click it.
71+
const label = await session.evaluate<string | null>(
72+
`(() => { const el = ${VISIBLE_CHIPS_EXPR}[0]; return el ? (el.textContent || '').trim() : null; })()`,
73+
);
74+
if (!label) throw new Error('could not read the first suggestion label');
75+
76+
const clicked = await session.evaluate<boolean>(
77+
`(() => { const el = ${VISIBLE_CHIPS_EXPR}[0]; if (!el) return false; el.click(); return true; })()`,
78+
);
79+
if (!clicked) throw new Error('failed to click the first suggestion chip');
80+
81+
// 5. The composer is populated with the suggestion's prompt. The prompt is a
82+
// full sentence, so it must be non-empty and longer than the short label —
83+
// proving it seeded the *prompt*, not merely echoed the chip title.
84+
await session.waitForFunction(
85+
`(() => {
86+
const text = ${COMPOSER_TEXT_EXPR};
87+
return typeof text === 'string' && text.length > ${label.length} && text.length > 20;
88+
})()`,
89+
{
90+
timeoutMs: 8000,
91+
message: 'clicking a suggestion did not populate the composer with its prompt',
92+
},
93+
);
94+
95+
// 6. Once the composer has content, the suggestions surface goes away
96+
// (matching the empty-state-only behavior of the feature).
97+
await session.waitForFunction(
98+
`!document.querySelector(${JSON.stringify(SUGGESTIONS)})`,
99+
{
100+
timeoutMs: 8000,
101+
message: 'suggestions did not hide after the composer was populated',
102+
},
103+
);
104+
},
105+
};
106+
107+
export default assertion;

packages/shared/src/i18n/locales/de.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@
145145
"chat.contextUsage.title": "Kontextfenster",
146146
"chat.contextUsage.usedOfTotal": "{{used}} verwendet, {{total}} gesamt",
147147
"chat.emptyTitle": "Was sollen wir bauen?",
148+
"chat.suggestions.explain.title": "Diese Codebasis erklären",
149+
"chat.suggestions.explain.prompt": "Gib mir einen Überblick über diese Codebasis – ihren Zweck, die wichtigsten Komponenten und wie die Teile zusammenspielen.",
150+
"chat.suggestions.build.title": "Ein Feature bauen",
151+
"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.",
152+
"chat.suggestions.fix.title": "Einen Bug beheben",
153+
"chat.suggestions.fix.prompt": "Hilf mir, einen Bug zu finden und zu beheben. Ich beschreibe das Problem und du untersuchst den relevanten Code.",
154+
"chat.suggestions.tests.title": "Tests schreiben",
155+
"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.",
148156
"chat.enterSessionName": "Sitzungsname eingeben...",
149157
"chat.expandComposer": "Editor vergrößern",
150158
"chat.failedToStopSharing": "Freigabe konnte nicht beendet werden",

packages/shared/src/i18n/locales/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@
145145
"chat.contextUsage.title": "Context window",
146146
"chat.contextUsage.usedOfTotal": "{{used}} used, {{total}} total",
147147
"chat.emptyTitle": "What should we build?",
148+
"chat.suggestions.explain.title": "Explain this codebase",
149+
"chat.suggestions.explain.prompt": "Give me a high-level overview of this codebase — its purpose, main components, and how the pieces fit together.",
150+
"chat.suggestions.build.title": "Build a feature",
151+
"chat.suggestions.build.prompt": "Help me build a new feature. Ask me what it should do, then propose an approach and implement it.",
152+
"chat.suggestions.fix.title": "Fix a bug",
153+
"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.",
154+
"chat.suggestions.tests.title": "Write tests",
155+
"chat.suggestions.tests.prompt": "Write tests for a part of my project. Point me at what to cover and add thorough, passing tests.",
148156
"chat.enterSessionName": "Enter session name...",
149157
"chat.expandComposer": "Expand composer",
150158
"chat.failedToStopSharing": "Failed to stop sharing",

packages/shared/src/i18n/locales/es.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@
145145
"chat.contextUsage.title": "Ventana de contexto",
146146
"chat.contextUsage.usedOfTotal": "{{used}} usados, {{total}} en total",
147147
"chat.emptyTitle": "¿Qué deberíamos construir?",
148+
"chat.suggestions.explain.title": "Explicar este código",
149+
"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.",
150+
"chat.suggestions.build.title": "Crear una función",
151+
"chat.suggestions.build.prompt": "Ayúdame a crear una nueva función. Pregúntame qué debe hacer, propón un enfoque e impleméntalo.",
152+
"chat.suggestions.fix.title": "Corregir un error",
153+
"chat.suggestions.fix.prompt": "Ayúdame a localizar y corregir un error. Yo describo qué falla y tú investigas el código relevante.",
154+
"chat.suggestions.tests.title": "Escribir pruebas",
155+
"chat.suggestions.tests.prompt": "Escribe pruebas para una parte de mi proyecto. Indícame qué cubrir y añade pruebas completas que pasen.",
148156
"chat.enterSessionName": "Introduce el nombre de la sesión...",
149157
"chat.expandComposer": "Ampliar el editor",
150158
"chat.failedToStopSharing": "Error al detener la compartición",

packages/shared/src/i18n/locales/hu.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@
145145
"chat.contextUsage.title": "Kontextusablak",
146146
"chat.contextUsage.usedOfTotal": "{{used}} felhasználva, összesen {{total}}",
147147
"chat.emptyTitle": "Mit építsünk?",
148+
"chat.suggestions.explain.title": "Kódbázis bemutatása",
149+
"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.",
150+
"chat.suggestions.build.title": "Funkció létrehozása",
151+
"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.",
152+
"chat.suggestions.fix.title": "Hiba javítása",
153+
"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.",
154+
"chat.suggestions.tests.title": "Tesztek írása",
155+
"chat.suggestions.tests.prompt": "Írj teszteket a projektem egy részéhez. Mondd meg, mit fedjek le, és adj hozzá alapos, sikeresen lefutó teszteket.",
148156
"chat.enterSessionName": "Add meg a munkamenet nevét...",
149157
"chat.expandComposer": "Szerkesztő kibontása",
150158
"chat.failedToStopSharing": "A megosztás leállítása sikertelen",

packages/shared/src/i18n/locales/ja.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@
145145
"chat.contextUsage.title": "コンテキストウィンドウ",
146146
"chat.contextUsage.usedOfTotal": "{{used}} 使用済み、合計 {{total}}",
147147
"chat.emptyTitle": "何を構築しましょうか?",
148+
"chat.suggestions.explain.title": "このコードベースを解説する",
149+
"chat.suggestions.explain.prompt": "このコードベースの概要を教えてください。目的、主要なコンポーネント、そして各部分がどう組み合わさっているかを説明してください。",
150+
"chat.suggestions.build.title": "機能を作る",
151+
"chat.suggestions.build.prompt": "新しい機能の実装を手伝ってください。まず何をするべきか確認し、方針を提案してから実装してください。",
152+
"chat.suggestions.fix.title": "バグを修正する",
153+
"chat.suggestions.fix.prompt": "バグの特定と修正を手伝ってください。症状を説明するので、関連するコードを調査してください。",
154+
"chat.suggestions.tests.title": "テストを書く",
155+
"chat.suggestions.tests.prompt": "プロジェクトの一部にテストを追加してください。カバーすべき範囲を伝えるので、網羅的でパスするテストを書いてください。",
148156
"chat.enterSessionName": "セッション名を入力...",
149157
"chat.expandComposer": "エディターを拡大",
150158
"chat.failedToStopSharing": "共有の停止に失敗しました",

0 commit comments

Comments
 (0)