diff --git a/.changeset/web-empty-suggestions.md b/.changeset/web-empty-suggestions.md
new file mode 100644
index 00000000..7aff4b08
--- /dev/null
+++ b/.changeset/web-empty-suggestions.md
@@ -0,0 +1,5 @@
+---
+'@pymodel/pythinker-code': minor
+---
+
+Add starter suggestions to the empty conversation screen in the web UI. Each suggestion fills the composer for editing and does not send the message.
diff --git a/apps/pythinker-web/src/components/ConversationPane.vue b/apps/pythinker-web/src/components/ConversationPane.vue
index cddb00c1..94b39c88 100644
--- a/apps/pythinker-web/src/components/ConversationPane.vue
+++ b/apps/pythinker-web/src/components/ConversationPane.vue
@@ -162,6 +162,7 @@ function pickWorkspace(id: string): void {
const { t } = useI18n();
const idleMascotSrc = useApngRestart('/brand/mascot-idle.png');
+const starterSuggestionKeys = ['explainRepository', 'suggestFirstTask', 'runChecks', 'reviewWorkingTree'] as const;
// The align toggle was removed with its UI (6e50cb7) — reading layout is
// always centered now. Drop the old persisted preference so users who once
@@ -185,6 +186,10 @@ function loadComposerForEdit(value: string): void {
(dockedComposerRef.value ?? emptyComposerRef.value)?.loadForEdit(value);
}
+function loadSuggestion(key: (typeof starterSuggestionKeys)[number]): void {
+ loadComposerForEdit(t(`suggestions.${key}.prompt`));
+}
+
function handleCopyConversationCopied(): void {
copyConversationCopied.value = true;
if (copyConversationCopiedTimer !== null) clearTimeout(copyConversationCopiedTimer);
@@ -820,6 +825,19 @@ defineExpose({ loadComposerForEdit });
{{ t('composer.emptyConversationTitle') }}
{{ t('composer.emptyConversation') }}
+
+
+ {{ t(`suggestions.${suggestion}.title`) }}
+ {{ t(`suggestions.${suggestion}.description`) }}
+
+
@@ -1323,6 +1341,52 @@ defineExpose({ loadComposerForEdit });
text-overflow: ellipsis;
white-space: nowrap;
}
+.empty-suggestions {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, calc(var(--ui-font-size) * 20)), 1fr));
+ gap: calc(var(--ui-font-size) * 0.75) calc(var(--ui-font-size) * 1.5);
+ inline-size: min(100%, calc(var(--ui-font-size) * 44));
+ padding: calc(var(--ui-font-size) * 0.5) 0;
+}
+.empty-suggestion {
+ appearance: none;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ min-inline-size: 0;
+ inline-size: 100%;
+ padding: calc(var(--ui-font-size) * 0.5) calc(var(--ui-font-size) * 0.75);
+ border: 0;
+ background: none;
+ box-shadow: none;
+ color: var(--ink);
+ cursor: pointer;
+ font: inherit;
+ text-align: start;
+ animation: suggestion-rise 200ms ease-out both;
+ animation-delay: calc(var(--suggestion-index) * 45ms);
+}
+.empty-suggestion-title {
+ color: inherit;
+ font-size: var(--ui-font-size);
+ font-weight: 600;
+ line-height: 1.35;
+}
+.empty-suggestion-description {
+ margin-top: calc(var(--ui-font-size) * 0.2);
+ color: var(--muted);
+ font-size: var(--ui-font-size-sm);
+ line-height: 1.4;
+}
+.empty-suggestion:hover .empty-suggestion-title { color: var(--blue); }
+.empty-suggestion:focus-visible {
+ outline: calc(var(--ui-font-size) * 0.14) solid var(--blue);
+ outline-offset: calc(var(--ui-font-size) * 0.14);
+}
+@keyframes suggestion-rise {
+ from { opacity: 0; transform: translateY(calc(var(--ui-font-size) * 0.4)); }
+ to { opacity: 1; transform: translateY(0); }
+}
@keyframes halo-breathe {
0%, 100% { opacity: 0.55; transform: translateX(-50%) scale(1); }
50% { opacity: 0.9; transform: translateX(-50%) scale(1.06); }
@@ -1333,7 +1397,7 @@ defineExpose({ loadComposerForEdit });
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
- .empty-halo, .empty-hint-head, .empty-hint-text { animation: none; }
+ .empty-halo, .empty-hint-head, .empty-hint-text, .empty-suggestion { animation: none; }
}
.empty-add-workspace {
display: inline-flex;
diff --git a/apps/pythinker-web/src/i18n/locales/en/suggestions.ts b/apps/pythinker-web/src/i18n/locales/en/suggestions.ts
new file mode 100644
index 00000000..739a9ade
--- /dev/null
+++ b/apps/pythinker-web/src/i18n/locales/en/suggestions.ts
@@ -0,0 +1,22 @@
+export default {
+ explainRepository: {
+ title: 'Explain this repository',
+ description: 'See the main packages and how they work together.',
+ prompt: 'Explain this repository. Show the main packages and how they work together.',
+ },
+ suggestFirstTask: {
+ title: 'Find a good first task',
+ description: 'Inspect the code and suggest a small useful task.',
+ prompt: 'Inspect this repository and suggest a small, useful first task.',
+ },
+ runChecks: {
+ title: 'Run the project checks',
+ description: 'Run the relevant tests and report any failures.',
+ prompt: 'Run the relevant project checks and report any failures.',
+ },
+ reviewWorkingTree: {
+ title: 'Review the working tree',
+ description: 'Find bugs, risks, or missing tests in current changes.',
+ prompt: 'Review the current working tree for bugs, risks, and missing tests.',
+ },
+} as const;
diff --git a/apps/pythinker-web/src/i18n/locales/index.ts b/apps/pythinker-web/src/i18n/locales/index.ts
index ec846c25..66063677 100644
--- a/apps/pythinker-web/src/i18n/locales/index.ts
+++ b/apps/pythinker-web/src/i18n/locales/index.ts
@@ -5,6 +5,7 @@ import en_workspace from './en/workspace';
import en_conversation from './en/conversation';
import en_status from './en/status';
import en_composer from './en/composer';
+import en_suggestions from './en/suggestions';
import en_login from './en/login';
import en_providers from './en/providers';
import en_model from './en/model';
@@ -38,6 +39,7 @@ export const messages = {
conversation: en_conversation,
status: en_status,
composer: en_composer,
+ suggestions: en_suggestions,
login: en_login,
providers: en_providers,
model: en_model,
diff --git a/apps/pythinker-web/test/empty-suggestions.test.ts b/apps/pythinker-web/test/empty-suggestions.test.ts
new file mode 100644
index 00000000..cd88ba50
--- /dev/null
+++ b/apps/pythinker-web/test/empty-suggestions.test.ts
@@ -0,0 +1,194 @@
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { mount } from '@vue/test-utils';
+import { createI18n } from 'vue-i18n';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { defineComponent, h, nextTick } from 'vue';
+
+import ConversationPane from '../src/components/ConversationPane.vue';
+import Composer from '../src/components/Composer.vue';
+import { messages } from '../src/i18n/locales';
+import type { ChatTurn, ConversationStatus } from '../src/types';
+
+const sourcePath = (path: string) => fileURLToPath(new URL(path, import.meta.url));
+const conversationPaneSource = readFileSync(sourcePath('../src/components/ConversationPane.vue'), 'utf8');
+const touchedSources = {
+ 'ConversationPane.vue': conversationPaneSource,
+ 'i18n/locales/index.ts': readFileSync(sourcePath('../src/i18n/locales/index.ts'), 'utf8'),
+ 'i18n/locales/en/suggestions.ts': readFileSync(sourcePath('../src/i18n/locales/en/suggestions.ts'), 'utf8'),
+ 'test/empty-suggestions.test.ts': readFileSync(sourcePath('./empty-suggestions.test.ts'), 'utf8'),
+};
+
+// These literals predate starter suggestions in ConversationPane.vue.
+const preExistingColorLiterals = new Set([
+ '#000',
+ ['rgba', '(0, 0, 0, 0.28)'].join(''),
+ ['rgba', '(0, 0, 0, 0.14)'].join(''),
+ ['rgba', '(0, 0, 0, 0.12)'].join(''),
+ ['rgba', '(0, 0, 0, 0.18)'].join(''),
+]);
+const rawColorPattern = /#[0-9a-f]{3,8}\b|rgba?\([^)]*\)/gi;
+
+const status: ConversationStatus = {
+ model: 'pythinker-test',
+ modelId: 'pythinker-test',
+ ctxUsed: 0,
+ ctxMax: 0,
+ permission: 'manual',
+ branch: 'main',
+ cwd: '/repo',
+ isGitRepo: true,
+};
+
+const turn: ChatTurn = {
+ id: 'turn_1',
+ role: 'user',
+ text: 'A message already exists',
+};
+
+class MockResizeObserver {
+ constructor(_callback: ResizeObserverCallback) {}
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+}
+
+function createTestI18n() {
+ return createI18n({
+ legacy: false,
+ locale: 'en',
+ messages,
+ missingWarn: false,
+ fallbackWarn: false,
+ });
+}
+
+function createComposerStub(loadForEdit: (value: string) => void) {
+ return defineComponent({
+ name: 'ComposerStub',
+ setup(_, { expose }) {
+ expose({ loadForEdit });
+ return () => h('div', { class: 'composer-stub' });
+ },
+ });
+}
+
+function mountPane(extraProps: Record = {}, composer = createComposerStub(() => {})) {
+ vi.stubGlobal('ResizeObserver', MockResizeObserver);
+
+ return mount(ConversationPane, {
+ attachTo: document.body,
+ props: {
+ mobile: true,
+ turns: [],
+ tasks: [],
+ status,
+ sessionLoading: false,
+ running: false,
+ ...extraProps,
+ },
+ global: {
+ plugins: [createTestI18n()],
+ stubs: {
+ ChatHeader: true,
+ ChatPane: true,
+ ChatDock: true,
+ DynamicWorkflowCard: true,
+ Composer: composer,
+ },
+ },
+ });
+}
+
+afterEach(() => {
+ document.body.innerHTML = '';
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe('ConversationPane starter suggestions', () => {
+ it('renders four suggestions only for an empty session', () => {
+ const empty = mountPane();
+ expect(empty.findAll('.empty-suggestion')).toHaveLength(4);
+ expect(empty.findAll('.empty-suggestion-title').map((node) => node.text())).toEqual([
+ 'Explain this repository',
+ 'Find a good first task',
+ 'Run the project checks',
+ 'Review the working tree',
+ ]);
+
+ const existing = mountPane({ turns: [turn] });
+ expect(existing.findAll('.empty-suggestion')).toHaveLength(0);
+ });
+
+ it('loads the clicked prompt through the empty composer', async () => {
+ const loadForEdit = vi.fn();
+ const wrapper = mountPane({}, createComposerStub(loadForEdit));
+
+ await wrapper.get('.empty-suggestion').trigger('click');
+
+ expect(loadForEdit).toHaveBeenCalledWith(
+ 'Explain this repository. Show the main packages and how they work together.',
+ );
+ });
+
+ it('does not submit or send a message when a suggestion is clicked', async () => {
+ const wrapper = mountPane({}, Composer);
+ const suggestion = wrapper.get('.empty-suggestion');
+
+ await suggestion.trigger('click');
+ await nextTick();
+
+ expect(wrapper.emitted('submit')).toBeUndefined();
+ expect(wrapper.findComponent(Composer).emitted('submit')).toBeUndefined();
+ });
+
+ it('focuses the real composer after loading a suggestion', async () => {
+ const wrapper = mountPane({}, Composer);
+
+ await wrapper.get('.empty-suggestion').trigger('click');
+ await nextTick();
+
+ const textarea = wrapper.get('textarea.ph').element as HTMLTextAreaElement;
+ expect(textarea.value).toBe(
+ 'Explain this repository. Show the main packages and how they work together.',
+ );
+ expect(document.activeElement).toBe(textarea);
+ });
+});
+
+describe('starter suggestion styling and source guards', () => {
+ it('uses flat button styling with no base border, background, or shadow', () => {
+ const gridRule = conversationPaneSource.match(/\.empty-suggestions\s*\{([\s\S]*?)\n\}/)?.[1] ?? '';
+ const baseRule = conversationPaneSource.match(/\.empty-suggestion\s*\{([\s\S]*?)\n\}/)?.[1] ?? '';
+
+ expect(gridRule).toMatch(/grid-template-columns:\s*repeat\(auto-fit,/);
+ expect(gridRule).toMatch(/calc\(var\(--ui-font-size\) \* 20\)/);
+ expect(baseRule).not.toBe('');
+ expect(baseRule).toMatch(/border:\s*0;/);
+ expect(baseRule).toMatch(/background:\s*none;/);
+ expect(baseRule).toMatch(/box-shadow:\s*none;/);
+ expect(baseRule).not.toMatch(/border-radius/);
+ expect(baseRule).toMatch(/animation:\s*suggestion-rise 200ms/);
+ expect(baseRule).toMatch(/animation-delay:\s*calc\(var\(--suggestion-index\) \* 45ms\)/);
+ });
+
+ it('disables suggestion entry animation in the existing reduced-motion block', () => {
+ const reducedMotionStart = conversationPaneSource.lastIndexOf('@media (prefers-reduced-motion: reduce)');
+ const reducedMotionEnd = conversationPaneSource.indexOf('.empty-add-workspace', reducedMotionStart);
+ const reducedMotionBlock = conversationPaneSource.slice(reducedMotionStart, reducedMotionEnd);
+
+ expect(reducedMotionBlock).toContain('.empty-suggestion');
+ expect(reducedMotionBlock).toMatch(/\.empty-suggestion[^}]*animation:\s*none;/s);
+ });
+
+ it('keeps touched files free of dark utilities and new raw color literals', () => {
+ const darkUtility = ['dark', ':'].join('');
+
+ for (const [file, source] of Object.entries(touchedSources)) {
+ expect(source, file).not.toContain(darkUtility);
+ const literals = source.match(rawColorPattern) ?? [];
+ expect(literals.filter((literal) => !preExistingColorLiterals.has(literal)), file).toEqual([]);
+ }
+ });
+});