Skip to content

Commit b8f7b18

Browse files
committed
Agent is cancelled: display stop
1 parent d6f60d0 commit b8f7b18

5 files changed

Lines changed: 209 additions & 15 deletions

File tree

src/main/channels/utils/agent-service.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ export class AgentService {
189189
kind: 'error',
190190
message: 'ANTHROPIC_API_KEY is not set',
191191
} );
192-
this.emit( { kind: 'done', success: false } );
192+
this.emit( { kind: 'done', success: false, cancelled: false } );
193193
return;
194194
}
195195

@@ -199,15 +199,15 @@ export class AgentService {
199199
kind: 'error',
200200
message: `Project ${ this.projectId } is not linked.`,
201201
} );
202-
this.emit( { kind: 'done', success: false } );
202+
this.emit( { kind: 'done', success: false, cancelled: false } );
203203
return;
204204
}
205205
if ( ! fs.existsSync( project.path ) ) {
206206
this.emit( {
207207
kind: 'error',
208208
message: `Project folder no longer exists on disk: ${ project.path }`,
209209
} );
210-
this.emit( { kind: 'done', success: false } );
210+
this.emit( { kind: 'done', success: false, cancelled: false } );
211211
return;
212212
}
213213

@@ -278,13 +278,28 @@ export class AgentService {
278278
}
279279
} catch ( err ) {
280280
if ( abortController.signal.aborted ) {
281-
this.emit( { kind: 'done', success: false } );
281+
appendMessage( this.projectId, chatId, {
282+
kind: 'assistant',
283+
id: randomUUID(),
284+
text: '',
285+
cancelled: true,
286+
at: Date.now(),
287+
} );
288+
this.emit( {
289+
kind: 'done',
290+
success: false,
291+
cancelled: true,
292+
} );
282293
} else {
283294
this.emit( {
284295
kind: 'error',
285296
message: err instanceof Error ? err.message : String( err ),
286297
} );
287-
this.emit( { kind: 'done', success: false } );
298+
this.emit( {
299+
kind: 'done',
300+
success: false,
301+
cancelled: false,
302+
} );
288303
}
289304
} finally {
290305
if ( this.currentAbort === abortController ) {
@@ -511,6 +526,7 @@ export class AgentService {
511526
this.emit( {
512527
kind: 'done',
513528
success,
529+
cancelled: false,
514530
} );
515531
}
516532
}
@@ -581,7 +597,7 @@ export class AgentService {
581597
kind: 'error',
582598
message: err instanceof Error ? err.message : String( err ),
583599
} );
584-
this.emit( { kind: 'done', success: false } );
600+
this.emit( { kind: 'done', success: false, cancelled: false } );
585601
}
586602

587603
private emit( event: UnstampedEvent ): void {

src/renderer/App.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ export function App(): React.ReactElement {
265265
text: p.text,
266266
streaming: false,
267267
errored: p.errored,
268+
cancelled: p.cancelled,
268269
};
269270
}
270271
return {
@@ -392,7 +393,12 @@ export function App(): React.ReactElement {
392393
updateChatMessages( projectId, stream.chatId, ( list ) =>
393394
list.map( ( m ) =>
394395
m.kind === 'assistant' && m.id === stream.msgId
395-
? { ...m, streaming: false }
396+
? {
397+
...m,
398+
streaming: false,
399+
cancelled:
400+
event.cancelled || m.cancelled,
401+
}
396402
: m
397403
)
398404
);

src/renderer/index.css

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,6 +1215,66 @@ button.resources-grid-card:focus-visible {
12151215
color: #d70015;
12161216
}
12171217

1218+
.bubble-cancelled {
1219+
border-style: dashed;
1220+
}
1221+
1222+
.bubble-stopped {
1223+
margin-top: 4px;
1224+
font-size: 11px;
1225+
font-style: italic;
1226+
letter-spacing: 0.02em;
1227+
color: var(--sidebar-fg-muted);
1228+
}
1229+
1230+
.bubble-cancelled .bubble-text + .bubble-stopped {
1231+
padding-top: 4px;
1232+
border-top: 1px dashed var(--hairline);
1233+
}
1234+
1235+
.bubble-cancelled:not(:has(.bubble-text)) .bubble-stopped {
1236+
margin-top: 0;
1237+
}
1238+
1239+
.bubble-thinking {
1240+
display: inline-flex;
1241+
gap: 4px;
1242+
align-items: center;
1243+
padding: 2px 2px;
1244+
}
1245+
1246+
.bubble-thinking span {
1247+
display: inline-block;
1248+
width: 6px;
1249+
height: 6px;
1250+
border-radius: 50%;
1251+
background: currentcolor;
1252+
opacity: 0.35;
1253+
animation: bubble-thinking-pulse 1.2s ease-in-out infinite;
1254+
}
1255+
1256+
.bubble-thinking span:nth-child(2) {
1257+
animation-delay: 0.15s;
1258+
}
1259+
1260+
.bubble-thinking span:nth-child(3) {
1261+
animation-delay: 0.3s;
1262+
}
1263+
1264+
@keyframes bubble-thinking-pulse {
1265+
0%,
1266+
80%,
1267+
100% {
1268+
opacity: 0.3;
1269+
transform: scale(0.8);
1270+
}
1271+
1272+
40% {
1273+
opacity: 1;
1274+
transform: scale(1);
1275+
}
1276+
}
1277+
12181278
.bubble-tools {
12191279
display: flex;
12201280
flex-wrap: wrap;
@@ -1254,22 +1314,28 @@ button.resources-grid-card:focus-visible {
12541314
.composer-input {
12551315
flex: 1 1 auto;
12561316
resize: none;
1257-
min-height: 36px;
1258-
max-height: 160px;
1317+
min-height: 80px;
1318+
max-height: 50vh;
12591319
padding: 8px 44px 8px 10px;
12601320
font: inherit;
12611321
color: inherit;
12621322
background: var(--input-bg);
12631323
border: 1px solid var(--input-border);
12641324
border-radius: 8px;
12651325
outline: none;
1326+
overflow-y: auto;
12661327
}
12671328

12681329
.composer-input:focus {
12691330
border-color: var(--accent);
12701331
box-shadow: 0 0 0 2px var(--accent-glow);
12711332
}
12721333

1334+
.composer-input:disabled {
1335+
opacity: 0.55;
1336+
cursor: not-allowed;
1337+
}
1338+
12731339
.composer-send {
12741340
position: absolute;
12751341
right: 8px;

src/renderer/screens/ProjectScreen.tsx

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useRef, useState } from 'react';
1+
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
22

33
import type { ChatMeta } from '../../types';
44

@@ -31,6 +31,7 @@ export type AssistantMessage = {
3131
text: string;
3232
streaming: boolean;
3333
errored?: boolean;
34+
cancelled?: boolean;
3435
};
3536

3637
export type ToolMessage = {
@@ -170,6 +171,9 @@ export function ProjectScreen( {
170171
const editInputRef = useRef< HTMLInputElement | null >( null );
171172
const [ addMenuOpen, setAddMenuOpen ] = useState( false );
172173
const addMenuRef = useRef< HTMLDivElement | null >( null );
174+
const transcriptRef = useRef< HTMLElement | null >( null );
175+
const composerInputRef = useRef< HTMLTextAreaElement | null >( null );
176+
const lastUserIdRef = useRef< string | null >( null );
173177

174178
useEffect( () => {
175179
if ( editingChatId ) {
@@ -178,6 +182,56 @@ export function ProjectScreen( {
178182
}
179183
}, [ editingChatId ] );
180184

185+
// Resync the composer height whenever the input value changes — the
186+
// textarea grows with content, capped by CSS max-height (50vh).
187+
useLayoutEffect( () => {
188+
const el = composerInputRef.current;
189+
if ( ! el ) {
190+
return;
191+
}
192+
el.style.height = 'auto';
193+
el.style.height = `${ el.scrollHeight }px`;
194+
}, [ input ] );
195+
196+
// Don't scroll on chat switch: reset the tracker so the next "new user
197+
// message" detection fires only when the user actually sends.
198+
useEffect( () => {
199+
lastUserIdRef.current = null;
200+
}, [ activeChatId ] );
201+
202+
// When a new user message appears at the tail of the transcript, scroll
203+
// the transcript to the bottom so the just-sent message and the
204+
// (about-to-stream) assistant bubble are visible.
205+
useEffect( () => {
206+
let lastUser: UserMessage | null = null;
207+
for ( let i = messages.length - 1; i >= 0; i-- ) {
208+
const m = messages[ i ];
209+
if ( m.kind === 'user' ) {
210+
lastUser = m;
211+
break;
212+
}
213+
}
214+
if ( ! lastUser ) {
215+
return;
216+
}
217+
if ( lastUser.id === lastUserIdRef.current ) {
218+
return;
219+
}
220+
const isFirstSee = lastUserIdRef.current === null;
221+
lastUserIdRef.current = lastUser.id;
222+
if ( isFirstSee ) {
223+
return;
224+
}
225+
const transcript = transcriptRef.current;
226+
if ( ! transcript ) {
227+
return;
228+
}
229+
transcript.scrollTo( {
230+
top: transcript.scrollHeight,
231+
behavior: 'smooth',
232+
} );
233+
}, [ messages ] );
234+
181235
const startEditingTab = ( chatId: string ): void => {
182236
const current = chats.find( ( c ) => c.id === chatId );
183237
setEditingChatId( chatId );
@@ -632,7 +686,11 @@ export function ProjectScreen( {
632686
</div>
633687
) }
634688

635-
<main className="transcript" data-testid="transcript">
689+
<main
690+
className="transcript"
691+
data-testid="transcript"
692+
ref={ transcriptRef }
693+
>
636694
{ groupMessages( messages ).map( ( item ) => {
637695
if ( item.kind === 'user' ) {
638696
return (
@@ -648,20 +706,65 @@ export function ProjectScreen( {
648706
);
649707
}
650708
if ( item.kind === 'assistant' ) {
709+
const isWorking =
710+
item.streaming && item.text.length === 0;
711+
const isCancelled =
712+
! item.streaming && !! item.cancelled;
713+
// Drop bubbles that finished with no text and
714+
// weren't cancelled (e.g. tool-only turns):
715+
// they used to render as silent empty bubbles.
716+
if (
717+
! item.streaming &&
718+
! isCancelled &&
719+
item.text.length === 0
720+
) {
721+
return null;
722+
}
651723
return (
652724
<div
653725
key={ item.id }
654726
className={ `bubble bubble-assistant${
655727
item.errored ? ' bubble-error' : ''
728+
}${
729+
isCancelled
730+
? ' bubble-cancelled'
731+
: ''
656732
}` }
657733
data-testid="bubble-assistant"
658734
data-streaming={
659735
item.streaming ? 'true' : 'false'
660736
}
737+
data-cancelled={
738+
isCancelled ? 'true' : 'false'
739+
}
661740
>
662-
<div className="bubble-text">
663-
{ item.text }
664-
</div>
741+
{ isWorking ? (
742+
<div
743+
className="bubble-thinking"
744+
data-testid="bubble-thinking"
745+
aria-label="Assistant is working"
746+
>
747+
<span />
748+
<span />
749+
<span />
750+
</div>
751+
) : (
752+
<>
753+
{ item.text.length > 0 && (
754+
<div className="bubble-text">
755+
{ item.text }
756+
</div>
757+
) }
758+
{ isCancelled && (
759+
<div
760+
className="bubble-stopped"
761+
data-testid="bubble-stopped"
762+
>
763+
Stopped
764+
</div>
765+
) }
766+
</>
767+
) }
665768
</div>
666769
);
667770
}
@@ -774,14 +877,15 @@ export function ProjectScreen( {
774877
<div className="composer" data-testid="composer">
775878
<div className="composer-field">
776879
<textarea
880+
ref={ composerInputRef }
777881
className="composer-input"
778882
data-testid="chat-input"
779883
placeholder={
780884
activeProjectId
781885
? 'Message Studio Write… (Enter to send, Shift+Enter for newline)'
782886
: 'Link a project to start chatting'
783887
}
784-
rows={ 3 }
888+
rows={ 1 }
785889
value={ input }
786890
onChange={ ( e ) =>
787891
onInputChange( e.target.value )

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const PersistedAssistant = z.object( {
4545
id: z.string(),
4646
text: z.string(),
4747
errored: z.boolean().optional(),
48+
cancelled: z.boolean().optional(),
4849
at: z.number(),
4950
} );
5051
const PersistedTool = z.object( {
@@ -146,6 +147,7 @@ export const AgentEvent = z.discriminatedUnion( 'kind', [
146147
kind: z.literal( 'done' ),
147148
projectId: z.string().min( 1 ),
148149
success: z.boolean(),
150+
cancelled: z.boolean(),
149151
} ),
150152
z.object( {
151153
kind: z.literal( 'error' ),

0 commit comments

Comments
 (0)