Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/web-empty-suggestions.md
Original file line number Diff line number Diff line change
@@ -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.
66 changes: 65 additions & 1 deletion apps/pythinker-web/src/components/ConversationPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -820,6 +825,19 @@ defineExpose({ loadComposerForEdit });
<span class="empty-hint-title">{{ t('composer.emptyConversationTitle') }}</span>
</div>
<span class="empty-hint-text">{{ t('composer.emptyConversation') }}</span>
<div class="empty-suggestions">
<button
v-for="(suggestion, index) in starterSuggestionKeys"
:key="suggestion"
type="button"
class="empty-suggestion"
:style="{ '--suggestion-index': index }"
@click="loadSuggestion(suggestion)"
>
<span class="empty-suggestion-title">{{ t(`suggestions.${suggestion}.title`) }}</span>
<span class="empty-suggestion-description">{{ t(`suggestions.${suggestion}.description`) }}</span>
</button>
</div>
<!-- Workspace picker: choose where this new conversation starts. -->
<div v-if="hasWorkspaces" class="ws-pick">
<button type="button" class="ws-pick-btn" :title="t('conversation.switchWorkspace')" @click.stop="wsPickOpen = !wsPickOpen">
Expand Down Expand Up @@ -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); }
Expand All @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions apps/pythinker-web/src/i18n/locales/en/suggestions.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions apps/pythinker-web/src/i18n/locales/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
194 changes: 194 additions & 0 deletions apps/pythinker-web/test/empty-suggestions.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}, 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([]);
}
});
});
Loading