Skip to content

Commit 4720ecb

Browse files
committed
feat(desktop): redesign sidebar input + add cancellation IPC
- Replace single-line input with autosize textarea (1–6 rows, max-h 144 px) - Move send button into bottom-right corner of textarea container, icon-only 28 px - During generation, send button becomes a stop button (Square icon) wired to cancelGeneration - Remove redundant keyboard-hint bar; fold hint into placeholder text - Enter sends, Shift+Enter inserts newline, ⌘↵ global send preserved - main: extract runGenerate helper; maintain Map<id, AbortController> for in-flight requests; new codesign:cancel-generation IPC handler; logger scope 'ipc' - preload: expose cancelGeneration(id) on window.codesign - shared: GeneratePayload accepts optional generationId - store: add activeGenerationId state + cancelGeneration action; suppress abort errors silently without pushing an error toast
1 parent 2575478 commit 4720ecb

5 files changed

Lines changed: 171 additions & 94 deletions

File tree

apps/desktop/src/main/index.ts

Lines changed: 79 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,62 @@ function createWindow(): void {
5050
}
5151
}
5252

53+
interface RunGenerateArgs {
54+
payload: GeneratePayload;
55+
controller: AbortController;
56+
logIpc: ReturnType<typeof getLogger>;
57+
}
58+
59+
async function runGenerate({ payload, controller, logIpc }: RunGenerateArgs): Promise<unknown> {
60+
const apiKey = getApiKeyForProvider(payload.model.provider);
61+
const storedBaseUrl = getBaseUrlForProvider(payload.model.provider);
62+
const baseUrl = payload.baseUrl ?? storedBaseUrl;
63+
const id = payload.generationId ?? `gen-${Date.now()}`;
64+
65+
logIpc.info('generate', {
66+
id,
67+
provider: payload.model.provider,
68+
modelId: payload.model.modelId,
69+
promptLen: payload.prompt.length,
70+
historyLen: payload.history.length,
71+
baseUrl: baseUrl ?? '<default>',
72+
});
73+
const t0 = Date.now();
74+
try {
75+
const result = await generate({
76+
prompt: payload.prompt,
77+
history: payload.history,
78+
model: payload.model,
79+
apiKey,
80+
...(baseUrl !== undefined ? { baseUrl } : {}),
81+
signal: controller.signal,
82+
});
83+
logIpc.info('generate.ok', {
84+
id,
85+
ms: Date.now() - t0,
86+
artifacts: (result as { artifacts?: unknown[] }).artifacts?.length ?? 0,
87+
cost: (result as { costUsd?: number }).costUsd,
88+
});
89+
return result;
90+
} catch (err) {
91+
logIpc.error('generate.fail', {
92+
id,
93+
ms: Date.now() - t0,
94+
provider: payload.model.provider,
95+
modelId: payload.model.modelId,
96+
baseUrl: baseUrl ?? '<default>',
97+
message: err instanceof Error ? err.message : String(err),
98+
code: err instanceof CodesignError ? err.code : undefined,
99+
});
100+
throw err;
101+
}
102+
}
103+
53104
function registerIpcHandlers(): void {
54-
const logIpc = getLogger('main:ipc');
105+
const logIpc = getLogger('ipc');
106+
107+
/** In-flight requests: generationId → AbortController */
108+
const inFlight = new Map<string, AbortController>();
55109

56110
ipcMain.handle('codesign:detect-provider', (_e, key: unknown) => {
57111
if (typeof key !== 'string') {
@@ -62,41 +116,31 @@ function registerIpcHandlers(): void {
62116

63117
ipcMain.handle('codesign:generate', async (_e, raw: unknown) => {
64118
const payload = GeneratePayload.parse(raw);
65-
const apiKey = getApiKeyForProvider(payload.model.provider);
66-
const storedBaseUrl = getBaseUrlForProvider(payload.model.provider);
67-
const baseUrl = payload.baseUrl ?? storedBaseUrl;
68-
logIpc.info('generate', {
69-
provider: payload.model.provider,
70-
modelId: payload.model.modelId,
71-
promptLen: payload.prompt.length,
72-
historyLen: payload.history.length,
73-
baseUrl: baseUrl ?? '<default>',
74-
});
75-
const t0 = Date.now();
119+
const controller = new AbortController();
120+
const id = payload.generationId ?? `gen-${Date.now()}`;
121+
inFlight.set(id, controller);
76122
try {
77-
const result = await generate({
78-
prompt: payload.prompt,
79-
history: payload.history,
80-
model: payload.model,
81-
apiKey,
82-
...(baseUrl !== undefined ? { baseUrl } : {}),
83-
});
84-
logIpc.info('generate.ok', {
85-
ms: Date.now() - t0,
86-
artifacts: (result as { artifacts?: unknown[] }).artifacts?.length ?? 0,
87-
cost: (result as { costUsd?: number }).costUsd,
88-
});
89-
return result;
90-
} catch (err) {
91-
logIpc.error('generate.fail', {
92-
ms: Date.now() - t0,
93-
provider: payload.model.provider,
94-
modelId: payload.model.modelId,
95-
baseUrl: baseUrl ?? '<default>',
96-
message: err instanceof Error ? err.message : String(err),
97-
code: err instanceof CodesignError ? err.code : undefined,
98-
});
99-
throw err;
123+
return await runGenerate({ payload, controller, logIpc });
124+
} finally {
125+
inFlight.delete(id);
126+
}
127+
});
128+
129+
ipcMain.handle('codesign:cancel-generation', (_e, raw: unknown) => {
130+
const id = typeof raw === 'string' ? raw : undefined;
131+
if (id !== undefined) {
132+
const controller = inFlight.get(id);
133+
if (controller) {
134+
controller.abort();
135+
inFlight.delete(id);
136+
logIpc.info('generate.cancelled', { id });
137+
}
138+
} else {
139+
for (const [key, controller] of inFlight) {
140+
controller.abort();
141+
logIpc.info('generate.cancelled', { id: key });
142+
}
143+
inFlight.clear();
100144
}
101145
});
102146

apps/desktop/src/preload/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ const api = {
3131
history: ChatMessage[];
3232
model: ModelRef;
3333
baseUrl?: string;
34+
generationId?: string;
3435
}) => ipcRenderer.invoke('codesign:generate', payload),
36+
cancelGeneration: (id: string) => ipcRenderer.invoke('codesign:cancel-generation', id),
3537
export: (payload: { format: ExportFormat; htmlContent: string; defaultFilename?: string }) =>
3638
ipcRenderer.invoke('codesign:export', payload) as Promise<ExportInvokeResponse>,
3739
checkForUpdates: () => ipcRenderer.invoke('codesign:check-for-updates'),

apps/desktop/src/renderer/src/components/Sidebar.tsx

Lines changed: 40 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ArrowUp } from 'lucide-react';
1+
import { ArrowUp, Square } from 'lucide-react';
22
import { useEffect, useRef } from 'react';
33
import { useCodesignStore } from '../store';
44

@@ -8,25 +8,31 @@ export interface SidebarProps {
88
onSubmit: () => void;
99
}
1010

11+
const MAX_TEXTAREA_HEIGHT = 144; // 6 lines × ~24px
12+
13+
function resizeTextarea(el: HTMLTextAreaElement): void {
14+
el.style.height = 'auto';
15+
el.style.height = `${Math.min(el.scrollHeight, MAX_TEXTAREA_HEIGHT)}px`;
16+
}
17+
1118
export function Sidebar({ prompt, setPrompt, onSubmit }: SidebarProps) {
1219
const messages = useCodesignStore((s) => s.messages);
1320
const isGenerating = useCodesignStore((s) => s.isGenerating);
21+
const cancelGeneration = useCodesignStore((s) => s.cancelGeneration);
1422
const taRef = useRef<HTMLTextAreaElement>(null);
1523

1624
useEffect(() => {
17-
const ta = taRef.current;
18-
if (!ta) return;
19-
ta.style.height = 'auto';
20-
ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`;
25+
// sync height on mount to match any pre-filled value
26+
if (taRef.current) resizeTextarea(taRef.current);
2127
}, []);
2228

23-
function handleSubmit(e: React.FormEvent) {
29+
function handleSubmit(e: React.FormEvent): void {
2430
e.preventDefault();
2531
if (!prompt.trim() || isGenerating) return;
2632
onSubmit();
2733
}
2834

29-
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
35+
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
3036
if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
3137
e.preventDefault();
3238
handleSubmit(e);
@@ -59,53 +65,43 @@ export function Sidebar({ prompt, setPrompt, onSubmit }: SidebarProps) {
5965
</div>
6066

6167
<form onSubmit={handleSubmit} className="border-t border-[var(--color-border-muted)] p-4">
62-
<div className="relative flex items-end gap-2 p-2 rounded-[var(--radius-lg)] bg-[var(--color-surface)] border border-[var(--color-border)] focus-within:border-[var(--color-accent)] focus-within:shadow-[0_0_0_3px_var(--color-focus-ring)] transition-[box-shadow,border-color] duration-150 ease-[var(--ease-out)]">
68+
<div className="relative rounded-[var(--radius-lg)] bg-[var(--color-surface)] border border-[var(--color-border)] focus-within:border-[var(--color-accent)] focus-within:shadow-[0_0_0_3px_var(--color-focus-ring)] transition-[box-shadow,border-color] duration-150 ease-[var(--ease-out)]">
6369
<textarea
6470
ref={taRef}
6571
value={prompt}
6672
onChange={(e) => {
6773
setPrompt(e.target.value);
68-
e.currentTarget.style.height = 'auto';
69-
e.currentTarget.style.height = `${Math.min(e.currentTarget.scrollHeight, 160)}px`;
74+
resizeTextarea(e.currentTarget);
7075
}}
7176
onKeyDown={handleKeyDown}
72-
placeholder="Describe what to design…"
77+
placeholder="Describe what to design… (Enter to send, Shift+Enter for newline)"
7378
disabled={isGenerating}
7479
rows={1}
75-
className="flex-1 resize-none bg-transparent px-2 py-1 text-[var(--text-sm)] leading-[1.5] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none min-h-[24px] max-h-[160px]"
80+
className="block w-full resize-none bg-transparent px-3 pt-3 pb-10 text-[var(--text-sm)] leading-[1.5] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none min-h-[24px] max-h-[144px] overflow-y-auto"
7681
/>
77-
<button
78-
type="submit"
79-
disabled={!canSend}
80-
aria-label="Send prompt"
81-
className="shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-[var(--radius-md)] bg-[var(--color-accent)] text-white shadow-[var(--shadow-soft)] hover:bg-[var(--color-accent-hover)] hover:scale-[1.04] active:scale-[0.96] disabled:opacity-30 disabled:hover:scale-100 disabled:pointer-events-none transition-[transform,background-color,opacity] duration-150 ease-[var(--ease-out)]"
82-
>
83-
<ArrowUp className="w-4 h-4" strokeWidth={2.4} />
84-
</button>
85-
</div>
86-
<div className="mt-2 px-1 text-[11px] text-[var(--color-text-muted)] flex items-center justify-between">
87-
<span>
88-
<kbd
89-
className="px-[5px] py-[1px] rounded-[4px] bg-[var(--color-surface-active)] text-[10px] text-[var(--color-text-secondary)]"
90-
style={{ fontFamily: 'var(--font-mono)' }}
91-
>
92-
Enter
93-
</kbd>{' '}
94-
send ·{' '}
95-
<kbd
96-
className="px-[5px] py-[1px] rounded-[4px] bg-[var(--color-surface-active)] text-[10px] text-[var(--color-text-secondary)]"
97-
style={{ fontFamily: 'var(--font-mono)' }}
98-
>
99-
⌘↵
100-
</kbd>{' '}
101-
anywhere
102-
</span>
103-
{isGenerating ? (
104-
<span className="inline-flex items-center gap-1.5 text-[var(--color-accent)]">
105-
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-accent)] animate-pulse" />
106-
Generating
107-
</span>
108-
) : null}
82+
83+
{/* action button pinned to bottom-right inside the textarea container */}
84+
<div className="absolute bottom-2 right-2">
85+
{isGenerating ? (
86+
<button
87+
type="button"
88+
onClick={cancelGeneration}
89+
aria-label="Stop generation"
90+
className="inline-flex items-center justify-center w-7 h-7 rounded-[var(--radius-md)] bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] hover:scale-[1.04] active:scale-[0.96] transition-[transform,background-color] duration-150 ease-[var(--ease-out)]"
91+
>
92+
<Square className="w-3.5 h-3.5" strokeWidth={0} fill="currentColor" />
93+
</button>
94+
) : (
95+
<button
96+
type="submit"
97+
disabled={!canSend}
98+
aria-label="Send prompt"
99+
className="inline-flex items-center justify-center w-7 h-7 rounded-[var(--radius-md)] bg-[var(--color-accent)] text-white shadow-[var(--shadow-soft)] hover:bg-[var(--color-accent-hover)] hover:scale-[1.04] active:scale-[0.96] disabled:opacity-30 disabled:hover:scale-100 disabled:pointer-events-none transition-[transform,background-color,opacity] duration-150 ease-[var(--ease-out)]"
100+
>
101+
<ArrowUp className="w-3.5 h-3.5" strokeWidth={2.4} />
102+
</button>
103+
)}
104+
</div>
109105
</div>
110106
</form>
111107
</aside>

apps/desktop/src/renderer/src/store.ts

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
SupportedOnboardingProvider,
66
} from '@open-codesign/shared';
77
import { create } from 'zustand';
8+
import type { StoreApi } from 'zustand';
89
import type { CodesignApi, ExportFormat } from '../../preload/index';
910

1011
declare global {
@@ -28,6 +29,7 @@ interface CodesignState {
2829
messages: ChatMessage[];
2930
previewHtml: string | null;
3031
isGenerating: boolean;
32+
activeGenerationId: string | null;
3133
errorMessage: string | null;
3234
lastError: string | null;
3335
config: OnboardingState | null;
@@ -43,6 +45,7 @@ interface CodesignState {
4345
loadConfig: () => Promise<void>;
4446
completeOnboarding: (next: OnboardingState) => void;
4547
sendPrompt: (prompt: string) => Promise<void>;
48+
cancelGeneration: () => void;
4649
retryLastPrompt: () => Promise<void>;
4750
clearError: () => void;
4851
clearIframeErrors: () => void;
@@ -96,10 +99,44 @@ function modelRef(provider: SupportedOnboardingProvider, modelId: string): Model
9699
return { provider, modelId };
97100
}
98101

102+
type SetState = StoreApi<CodesignState>['setState'];
103+
type GetState = StoreApi<CodesignState>['getState'];
104+
105+
function applyGenerateSuccess(set: SetState, result: unknown): void {
106+
const r = result as { artifacts: Array<{ content: string }>; message: string };
107+
const firstArtifact = r.artifacts[0];
108+
const message = r.message;
109+
set((s) => ({
110+
messages: [...s.messages, { role: 'assistant', content: message || 'Done.' }],
111+
previewHtml: firstArtifact?.content ?? s.previewHtml,
112+
isGenerating: false,
113+
activeGenerationId: null,
114+
}));
115+
}
116+
117+
function applyGenerateError(get: GetState, set: SetState, err: unknown): void {
118+
const msg = err instanceof Error ? err.message : 'Unknown error';
119+
const lower = msg.toLowerCase();
120+
const isCancelled = lower.includes('abort') || lower.includes('cancel');
121+
set((s) => ({
122+
messages: isCancelled
123+
? s.messages
124+
: [...s.messages, { role: 'assistant', content: `Error: ${msg}` }],
125+
isGenerating: false,
126+
activeGenerationId: null,
127+
errorMessage: isCancelled ? null : msg,
128+
lastError: isCancelled ? s.lastError : msg,
129+
}));
130+
if (!isCancelled) {
131+
get().pushToast({ variant: 'error', title: 'Generation failed', description: msg });
132+
}
133+
}
134+
99135
export const useCodesignStore = create<CodesignState>((set, get) => ({
100136
messages: [],
101137
previewHtml: null,
102138
isGenerating: false,
139+
activeGenerationId: null,
103140
errorMessage: null,
104141
lastError: null,
105142
config: null,
@@ -146,10 +183,12 @@ export const useCodesignStore = create<CodesignState>((set, get) => ({
146183
return;
147184
}
148185

186+
const generationId = newId();
149187
const userMessage: ChatMessage = { role: 'user', content: prompt };
150188
set((s) => ({
151189
messages: [...s.messages, userMessage],
152190
isGenerating: true,
191+
activeGenerationId: generationId,
153192
errorMessage: null,
154193
}));
155194

@@ -158,26 +197,21 @@ export const useCodesignStore = create<CodesignState>((set, get) => ({
158197
prompt,
159198
history: get().messages,
160199
model: modelRef(cfg.provider, cfg.modelPrimary),
200+
generationId,
161201
});
162-
const firstArtifact = (result as { artifacts: Array<{ content: string }> }).artifacts[0];
163-
const message = (result as { message: string }).message;
164-
set((s) => ({
165-
messages: [...s.messages, { role: 'assistant', content: message || 'Done.' }],
166-
previewHtml: firstArtifact?.content ?? s.previewHtml,
167-
isGenerating: false,
168-
}));
202+
applyGenerateSuccess(set, result);
169203
} catch (err) {
170-
const msg = err instanceof Error ? err.message : 'Unknown error';
171-
set((s) => ({
172-
messages: [...s.messages, { role: 'assistant', content: `Error: ${msg}` }],
173-
isGenerating: false,
174-
errorMessage: msg,
175-
lastError: msg,
176-
}));
177-
get().pushToast({ variant: 'error', title: 'Generation failed', description: msg });
204+
applyGenerateError(get, set, err);
178205
}
179206
},
180207

208+
cancelGeneration() {
209+
const id = get().activeGenerationId;
210+
if (!id || !window.codesign) return;
211+
window.codesign.cancelGeneration(id);
212+
set({ isGenerating: false, activeGenerationId: null });
213+
},
214+
181215
async retryLastPrompt() {
182216
const lastUser = [...get().messages].reverse().find((m) => m.role === 'user');
183217
if (!lastUser) return;

packages/shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export const GeneratePayload = z.object({
8585
history: z.array(ChatMessage).max(200),
8686
model: ModelRef,
8787
baseUrl: z.string().url().optional(),
88+
generationId: z.string().optional(),
8889
});
8990
export type GeneratePayload = z.infer<typeof GeneratePayload>;
9091

0 commit comments

Comments
 (0)