Skip to content

Commit 8d1a677

Browse files
committed
feat(web): add starter suggestions to the empty conversation screen
The empty session showed only a blank composer. It now offers four starter prompts below the hero text. A click fills the composer for editing and does not send the message.
1 parent f97b801 commit 8d1a677

5 files changed

Lines changed: 288 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pymodel/pythinker-code': minor
3+
---
4+
5+
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.

apps/pythinker-web/src/components/ConversationPane.vue

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ function pickWorkspace(id: string): void {
162162
163163
const { t } = useI18n();
164164
const idleMascotSrc = useApngRestart('/brand/mascot-idle.png');
165+
const starterSuggestionKeys = ['explainRepository', 'suggestFirstTask', 'runChecks', 'reviewWorkingTree'] as const;
165166
166167
// The align toggle was removed with its UI (6e50cb7) — reading layout is
167168
// always centered now. Drop the old persisted preference so users who once
@@ -185,6 +186,10 @@ function loadComposerForEdit(value: string): void {
185186
(dockedComposerRef.value ?? emptyComposerRef.value)?.loadForEdit(value);
186187
}
187188
189+
function loadSuggestion(key: (typeof starterSuggestionKeys)[number]): void {
190+
loadComposerForEdit(t(`suggestions.${key}.prompt`));
191+
}
192+
188193
function handleCopyConversationCopied(): void {
189194
copyConversationCopied.value = true;
190195
if (copyConversationCopiedTimer !== null) clearTimeout(copyConversationCopiedTimer);
@@ -820,6 +825,19 @@ defineExpose({ loadComposerForEdit });
820825
<span class="empty-hint-title">{{ t('composer.emptyConversationTitle') }}</span>
821826
</div>
822827
<span class="empty-hint-text">{{ t('composer.emptyConversation') }}</span>
828+
<div class="empty-suggestions">
829+
<button
830+
v-for="(suggestion, index) in starterSuggestionKeys"
831+
:key="suggestion"
832+
type="button"
833+
class="empty-suggestion"
834+
:style="{ '--suggestion-index': index }"
835+
@click="loadSuggestion(suggestion)"
836+
>
837+
<span class="empty-suggestion-title">{{ t(`suggestions.${suggestion}.title`) }}</span>
838+
<span class="empty-suggestion-description">{{ t(`suggestions.${suggestion}.description`) }}</span>
839+
</button>
840+
</div>
823841
<!-- Workspace picker: choose where this new conversation starts. -->
824842
<div v-if="hasWorkspaces" class="ws-pick">
825843
<button type="button" class="ws-pick-btn" :title="t('conversation.switchWorkspace')" @click.stop="wsPickOpen = !wsPickOpen">
@@ -1323,6 +1341,52 @@ defineExpose({ loadComposerForEdit });
13231341
text-overflow: ellipsis;
13241342
white-space: nowrap;
13251343
}
1344+
.empty-suggestions {
1345+
display: grid;
1346+
grid-template-columns: repeat(auto-fit, minmax(min(100%, calc(var(--ui-font-size) * 20)), 1fr));
1347+
gap: calc(var(--ui-font-size) * 0.75) calc(var(--ui-font-size) * 1.5);
1348+
inline-size: min(100%, calc(var(--ui-font-size) * 44));
1349+
padding: calc(var(--ui-font-size) * 0.5) 0;
1350+
}
1351+
.empty-suggestion {
1352+
appearance: none;
1353+
display: flex;
1354+
flex-direction: column;
1355+
align-items: flex-start;
1356+
min-inline-size: 0;
1357+
inline-size: 100%;
1358+
padding: calc(var(--ui-font-size) * 0.5) calc(var(--ui-font-size) * 0.75);
1359+
border: 0;
1360+
background: none;
1361+
box-shadow: none;
1362+
color: var(--ink);
1363+
cursor: pointer;
1364+
font: inherit;
1365+
text-align: start;
1366+
animation: suggestion-rise 200ms ease-out both;
1367+
animation-delay: calc(var(--suggestion-index) * 45ms);
1368+
}
1369+
.empty-suggestion-title {
1370+
color: inherit;
1371+
font-size: var(--ui-font-size);
1372+
font-weight: 600;
1373+
line-height: 1.35;
1374+
}
1375+
.empty-suggestion-description {
1376+
margin-top: calc(var(--ui-font-size) * 0.2);
1377+
color: var(--muted);
1378+
font-size: var(--ui-font-size-sm);
1379+
line-height: 1.4;
1380+
}
1381+
.empty-suggestion:hover .empty-suggestion-title { color: var(--blue); }
1382+
.empty-suggestion:focus-visible {
1383+
outline: calc(var(--ui-font-size) * 0.14) solid var(--blue);
1384+
outline-offset: calc(var(--ui-font-size) * 0.14);
1385+
}
1386+
@keyframes suggestion-rise {
1387+
from { opacity: 0; transform: translateY(calc(var(--ui-font-size) * 0.4)); }
1388+
to { opacity: 1; transform: translateY(0); }
1389+
}
13261390
@keyframes halo-breathe {
13271391
0%, 100% { opacity: 0.55; transform: translateX(-50%) scale(1); }
13281392
50% { opacity: 0.9; transform: translateX(-50%) scale(1.06); }
@@ -1333,7 +1397,7 @@ defineExpose({ loadComposerForEdit });
13331397
to { opacity: 1; transform: translateY(0); }
13341398
}
13351399
@media (prefers-reduced-motion: reduce) {
1336-
.empty-halo, .empty-hint-head, .empty-hint-text { animation: none; }
1400+
.empty-halo, .empty-hint-head, .empty-hint-text, .empty-suggestion { animation: none; }
13371401
}
13381402
.empty-add-workspace {
13391403
display: inline-flex;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export default {
2+
explainRepository: {
3+
title: 'Explain this repository',
4+
description: 'See the main packages and how they work together.',
5+
prompt: 'Explain this repository. Show the main packages and how they work together.',
6+
},
7+
suggestFirstTask: {
8+
title: 'Find a good first task',
9+
description: 'Inspect the code and suggest a small useful task.',
10+
prompt: 'Inspect this repository and suggest a small, useful first task.',
11+
},
12+
runChecks: {
13+
title: 'Run the project checks',
14+
description: 'Run the relevant tests and report any failures.',
15+
prompt: 'Run the relevant project checks and report any failures.',
16+
},
17+
reviewWorkingTree: {
18+
title: 'Review the working tree',
19+
description: 'Find bugs, risks, or missing tests in current changes.',
20+
prompt: 'Review the current working tree for bugs, risks, and missing tests.',
21+
},
22+
} as const;

apps/pythinker-web/src/i18n/locales/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import en_workspace from './en/workspace';
55
import en_conversation from './en/conversation';
66
import en_status from './en/status';
77
import en_composer from './en/composer';
8+
import en_suggestions from './en/suggestions';
89
import en_login from './en/login';
910
import en_providers from './en/providers';
1011
import en_model from './en/model';
@@ -38,6 +39,7 @@ export const messages = {
3839
conversation: en_conversation,
3940
status: en_status,
4041
composer: en_composer,
42+
suggestions: en_suggestions,
4143
login: en_login,
4244
providers: en_providers,
4345
model: en_model,
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { readFileSync } from 'node:fs';
2+
import { fileURLToPath } from 'node:url';
3+
import { mount } from '@vue/test-utils';
4+
import { createI18n } from 'vue-i18n';
5+
import { afterEach, describe, expect, it, vi } from 'vitest';
6+
import { defineComponent, h, nextTick } from 'vue';
7+
8+
import ConversationPane from '../src/components/ConversationPane.vue';
9+
import Composer from '../src/components/Composer.vue';
10+
import { messages } from '../src/i18n/locales';
11+
import type { ChatTurn, ConversationStatus } from '../src/types';
12+
13+
const sourcePath = (path: string) => fileURLToPath(new URL(path, import.meta.url));
14+
const conversationPaneSource = readFileSync(sourcePath('../src/components/ConversationPane.vue'), 'utf8');
15+
const touchedSources = {
16+
'ConversationPane.vue': conversationPaneSource,
17+
'i18n/locales/index.ts': readFileSync(sourcePath('../src/i18n/locales/index.ts'), 'utf8'),
18+
'i18n/locales/en/suggestions.ts': readFileSync(sourcePath('../src/i18n/locales/en/suggestions.ts'), 'utf8'),
19+
'test/empty-suggestions.test.ts': readFileSync(sourcePath('./empty-suggestions.test.ts'), 'utf8'),
20+
};
21+
22+
// These literals predate starter suggestions in ConversationPane.vue.
23+
const preExistingColorLiterals = new Set([
24+
'#000',
25+
['rgba', '(0, 0, 0, 0.28)'].join(''),
26+
['rgba', '(0, 0, 0, 0.14)'].join(''),
27+
['rgba', '(0, 0, 0, 0.12)'].join(''),
28+
['rgba', '(0, 0, 0, 0.18)'].join(''),
29+
]);
30+
const rawColorPattern = /#[0-9a-f]{3,8}\b|rgba?\([^)]*\)/gi;
31+
32+
const status: ConversationStatus = {
33+
model: 'pythinker-test',
34+
modelId: 'pythinker-test',
35+
ctxUsed: 0,
36+
ctxMax: 0,
37+
permission: 'manual',
38+
branch: 'main',
39+
cwd: '/repo',
40+
isGitRepo: true,
41+
};
42+
43+
const turn: ChatTurn = {
44+
id: 'turn_1',
45+
role: 'user',
46+
text: 'A message already exists',
47+
};
48+
49+
class MockResizeObserver {
50+
constructor(_callback: ResizeObserverCallback) {}
51+
observe(): void {}
52+
unobserve(): void {}
53+
disconnect(): void {}
54+
}
55+
56+
function createTestI18n() {
57+
return createI18n({
58+
legacy: false,
59+
locale: 'en',
60+
messages,
61+
missingWarn: false,
62+
fallbackWarn: false,
63+
});
64+
}
65+
66+
function createComposerStub(loadForEdit: (value: string) => void) {
67+
return defineComponent({
68+
name: 'ComposerStub',
69+
setup(_, { expose }) {
70+
expose({ loadForEdit });
71+
return () => h('div', { class: 'composer-stub' });
72+
},
73+
});
74+
}
75+
76+
function mountPane(extraProps: Record<string, unknown> = {}, composer = createComposerStub(() => {})) {
77+
vi.stubGlobal('ResizeObserver', MockResizeObserver);
78+
79+
return mount(ConversationPane, {
80+
attachTo: document.body,
81+
props: {
82+
mobile: true,
83+
turns: [],
84+
tasks: [],
85+
status,
86+
sessionLoading: false,
87+
running: false,
88+
...extraProps,
89+
},
90+
global: {
91+
plugins: [createTestI18n()],
92+
stubs: {
93+
ChatHeader: true,
94+
ChatPane: true,
95+
ChatDock: true,
96+
DynamicWorkflowCard: true,
97+
Composer: composer,
98+
},
99+
},
100+
});
101+
}
102+
103+
afterEach(() => {
104+
document.body.innerHTML = '';
105+
vi.unstubAllGlobals();
106+
vi.restoreAllMocks();
107+
});
108+
109+
describe('ConversationPane starter suggestions', () => {
110+
it('renders four suggestions only for an empty session', () => {
111+
const empty = mountPane();
112+
expect(empty.findAll('.empty-suggestion')).toHaveLength(4);
113+
expect(empty.findAll('.empty-suggestion-title').map((node) => node.text())).toEqual([
114+
'Explain this repository',
115+
'Find a good first task',
116+
'Run the project checks',
117+
'Review the working tree',
118+
]);
119+
120+
const existing = mountPane({ turns: [turn] });
121+
expect(existing.findAll('.empty-suggestion')).toHaveLength(0);
122+
});
123+
124+
it('loads the clicked prompt through the empty composer', async () => {
125+
const loadForEdit = vi.fn();
126+
const wrapper = mountPane({}, createComposerStub(loadForEdit));
127+
128+
await wrapper.get('.empty-suggestion').trigger('click');
129+
130+
expect(loadForEdit).toHaveBeenCalledWith(
131+
'Explain this repository. Show the main packages and how they work together.',
132+
);
133+
});
134+
135+
it('does not submit or send a message when a suggestion is clicked', async () => {
136+
const wrapper = mountPane({}, Composer);
137+
const suggestion = wrapper.get('.empty-suggestion');
138+
139+
await suggestion.trigger('click');
140+
await nextTick();
141+
142+
expect(wrapper.emitted('submit')).toBeUndefined();
143+
expect(wrapper.findComponent(Composer).emitted('submit')).toBeUndefined();
144+
});
145+
146+
it('focuses the real composer after loading a suggestion', async () => {
147+
const wrapper = mountPane({}, Composer);
148+
149+
await wrapper.get('.empty-suggestion').trigger('click');
150+
await nextTick();
151+
152+
const textarea = wrapper.get('textarea.ph').element as HTMLTextAreaElement;
153+
expect(textarea.value).toBe(
154+
'Explain this repository. Show the main packages and how they work together.',
155+
);
156+
expect(document.activeElement).toBe(textarea);
157+
});
158+
});
159+
160+
describe('starter suggestion styling and source guards', () => {
161+
it('uses flat button styling with no base border, background, or shadow', () => {
162+
const gridRule = conversationPaneSource.match(/\.empty-suggestions\s*\{([\s\S]*?)\n\}/)?.[1] ?? '';
163+
const baseRule = conversationPaneSource.match(/\.empty-suggestion\s*\{([\s\S]*?)\n\}/)?.[1] ?? '';
164+
165+
expect(gridRule).toMatch(/grid-template-columns:\s*repeat\(auto-fit,/);
166+
expect(gridRule).toMatch(/calc\(var\(--ui-font-size\) \* 20\)/);
167+
expect(baseRule).not.toBe('');
168+
expect(baseRule).toMatch(/border:\s*0;/);
169+
expect(baseRule).toMatch(/background:\s*none;/);
170+
expect(baseRule).toMatch(/box-shadow:\s*none;/);
171+
expect(baseRule).not.toMatch(/border-radius/);
172+
expect(baseRule).toMatch(/animation:\s*suggestion-rise 200ms/);
173+
expect(baseRule).toMatch(/animation-delay:\s*calc\(var\(--suggestion-index\) \* 45ms\)/);
174+
});
175+
176+
it('disables suggestion entry animation in the existing reduced-motion block', () => {
177+
const reducedMotionStart = conversationPaneSource.lastIndexOf('@media (prefers-reduced-motion: reduce)');
178+
const reducedMotionEnd = conversationPaneSource.indexOf('.empty-add-workspace', reducedMotionStart);
179+
const reducedMotionBlock = conversationPaneSource.slice(reducedMotionStart, reducedMotionEnd);
180+
181+
expect(reducedMotionBlock).toContain('.empty-suggestion');
182+
expect(reducedMotionBlock).toMatch(/\.empty-suggestion[^}]*animation:\s*none;/s);
183+
});
184+
185+
it('keeps touched files free of dark utilities and new raw color literals', () => {
186+
const darkUtility = ['dark', ':'].join('');
187+
188+
for (const [file, source] of Object.entries(touchedSources)) {
189+
expect(source, file).not.toContain(darkUtility);
190+
const literals = source.match(rawColorPattern) ?? [];
191+
expect(literals.filter((literal) => !preExistingColorLiterals.has(literal)), file).toEqual([]);
192+
}
193+
});
194+
});

0 commit comments

Comments
 (0)