Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1698,6 +1699,16 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
{t('chat.emptyTitle')}
</h1>
{renderChatInputZone('mt-0 px-0 pb-0 @xs/panel:px-0')}
{(inputValue ?? '').trim().length === 0 ? (
<EmptyStateSuggestions
disabled={isInputDisabled || disableSend || connectionUnavailable}
onSelect={(prompt) => {
onInputChange?.(prompt)
// Let the controlled value propagate to the composer, then focus.
requestAnimationFrame(() => textareaRef.current?.focus())
}}
/>
) : null}
</motion.div>
</div>
) : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.<id>.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 (
<div
data-testid="empty-suggestions"
className={cn(
'mx-auto mt-4 grid w-full max-w-[840px] grid-cols-1 gap-2 sm:grid-cols-2',
className,
)}
>
{SUGGESTIONS.map(({ id, icon: Icon }) => {
const title = t(`chat.suggestions.${id}.title`)
const prompt = t(`chat.suggestions.${id}.prompt`)
return (
<button
key={id}
type="button"
data-testid="empty-suggestion"
data-suggestion-id={id}
onClick={() => onSelect(prompt)}
className={cn(
'group flex items-center gap-2.5 rounded-[10px] border bg-muted/20 px-3.5 py-2.5 text-left',
'text-sm text-foreground/80 transition-colors',
'hover:bg-muted/50 hover:text-foreground',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-ring',
)}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground group-hover:text-foreground" />
<span className="min-w-0 truncate font-medium">{title}</span>
</button>
)
})}
</div>
)
}
1 change: 1 addition & 0 deletions docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
| 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. |
| 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). |
| 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). |
Expand Down
107 changes: 107 additions & 0 deletions e2e/assertions/starter-suggestions.assert.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(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<string | null>(
`(() => { 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<boolean>(
`(() => { 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;
8 changes: 8 additions & 0 deletions packages/shared/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,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.expandComposer": "Editor vergrößern",
"chat.failedToStopSharing": "Freigabe konnte nicht beendet werden",
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,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.expandComposer": "Expand composer",
"chat.failedToStopSharing": "Failed to stop sharing",
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,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.expandComposer": "Ampliar el editor",
"chat.failedToStopSharing": "Error al detener la compartición",
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/i18n/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,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.expandComposer": "Szerkesztő kibontása",
"chat.failedToStopSharing": "A megosztás leállítása sikertelen",
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,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.expandComposer": "エディターを拡大",
"chat.failedToStopSharing": "共有の停止に失敗しました",
Expand Down
Loading