Skip to content

Commit 12af8ec

Browse files
authored
Merge pull request #3317 from codecrafters-io/improve-autofix-request-ui
feat(autofix-request-card): improve UI for in-progress and success states
2 parents f3902b9 + 2280282 commit 12af8ec

File tree

6 files changed

+336
-50
lines changed

6 files changed

+336
-50
lines changed

app/components/course-page/test-results-bar/autofix-request-card.hbs

Lines changed: 46 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -18,59 +18,55 @@
1818
{{/if}}
1919
{{/animated-value}}
2020

21-
<BlurredOverlay @isBlurred={{this.explanationIsBlurred}} @overlayClass="bg-gray-950/20" class="-mx-4 -mb-4">
22-
<:content>
23-
<div class="p-4">
24-
<div class="prose prose-sm dark:prose-invert has-prism-highlighting">
25-
{{#if (eq @autofixRequest.status "in_progress")}}
26-
<p>
27-
This is the content of a sample hint. We use this as a
28-
<code>placeholder</code>
29-
for the actual hint. It's blurred out so you shouldn't be seeing this...
30-
</p>
31-
{{else}}
21+
{{#if (eq @autofixRequest.status "in_progress")}}
22+
<CoursePage::TestResultsBar::AutofixRequestCard::LogstreamSection class="mt-3" @autofixRequest={{@autofixRequest}} />
23+
{{else}}
24+
<BlurredOverlay @isBlurred={{this.explanationIsBlurred}} @overlayClass="bg-gray-950/20" class="-mx-4 -mb-4">
25+
<:content>
26+
<div class="p-4">
27+
<div class="prose prose-sm dark:prose-invert has-prism-highlighting">
3228
{{markdown-to-html @autofixRequest.explanationMarkdown}}
33-
{{/if}}
29+
</div>
30+
31+
<BlurredOverlay @isBlurred={{and this.diffIsBlurred (not this.explanationIsBlurred)}} @overlayClass="bg-gray-950/20" class="-mx-4 -mb-4">
32+
<:content>
33+
<div class="p-4 flex flex-col gap-6">
34+
{{#each this.changedFilesForRender key="filename" as |changedFile|}}
35+
<FileDiffCard
36+
@code={{changedFile.diff}}
37+
@filename={{changedFile.filename}}
38+
@forceDarkTheme={{true}}
39+
{{! @glint-expect-error language can be nullable }}
40+
@language={{@autofixRequest.submission.repository.language.slug}}
41+
/>
42+
{{/each}}
43+
</div>
44+
</:content>
45+
<:overlay>
46+
{{#if (eq @autofixRequest.status "success")}}
47+
<SecondaryButton class="bg-gray-900 mt-10" {{on "click" this.handleShowFixedCodeButtonClick}}>
48+
<div class="flex items-center gap-2">
49+
<div class="flex">{{svg-jar "eye" class="size-6"}}</div>
50+
Show fixed code
51+
</div>
52+
</SecondaryButton>
53+
{{/if}}
54+
</:overlay>
55+
</BlurredOverlay>
3456
</div>
57+
</:content>
3558

36-
<BlurredOverlay @isBlurred={{and this.diffIsBlurred (not this.explanationIsBlurred)}} @overlayClass="bg-gray-950/20" class="-mx-4 -mb-4">
37-
<:content>
38-
<div class="p-4 flex flex-col gap-6">
39-
{{#each this.changedFilesForRender key="filename" as |changedFile|}}
40-
<FileDiffCard
41-
@code={{changedFile.diff}}
42-
@filename={{changedFile.filename}}
43-
@forceDarkTheme={{true}}
44-
{{! @glint-expect-error language can be nullable }}
45-
@language={{@autofixRequest.submission.repository.language.slug}}
46-
/>
47-
{{/each}}
59+
<:overlay>
60+
{{#if (eq @autofixRequest.status "success")}}
61+
<SecondaryButton class="bg-gray-900 mt-10" {{on "click" this.handleShowExplanationButtonClick}}>
62+
<div class="flex items-center gap-2">
63+
<div class="flex">{{svg-jar "eye" class="size-6"}}</div>
64+
Explain more?
4865
</div>
49-
</:content>
50-
<:overlay>
51-
{{#if (eq @autofixRequest.status "success")}}
52-
<SecondaryButton class="bg-gray-900 mt-10" {{on "click" this.handleShowFixedCodeButtonClick}}>
53-
<div class="flex items-center gap-2">
54-
<div class="flex">{{svg-jar "eye" class="size-6"}}</div>
55-
Show fixed code
56-
</div>
57-
</SecondaryButton>
58-
{{/if}}
59-
</:overlay>
60-
</BlurredOverlay>
61-
</div>
62-
</:content>
63-
64-
<:overlay>
65-
{{#if (eq @autofixRequest.status "success")}}
66-
<SecondaryButton class="bg-gray-900 mt-10" {{on "click" this.handleShowExplanationButtonClick}}>
67-
<div class="flex items-center gap-2">
68-
<div class="flex">{{svg-jar "eye" class="size-6"}}</div>
69-
Explain more?
70-
</div>
71-
</SecondaryButton>
72-
{{/if}}
73-
</:overlay>
74-
</BlurredOverlay>
66+
</SecondaryButton>
67+
{{/if}}
68+
</:overlay>
69+
</BlurredOverlay>
70+
{{/if}}
7571
</AnimatedContainer>
7672
</div>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<div class="overflow-y-auto h-32" {{did-insert this.handleDidInsertEventsContainer}} ...attributes>
2+
<AnimatedContainer>
3+
{{#animated-each this.toolCalls key="tool_call_id" use=this.listTransition duration=300 as |toolCall|}}
4+
<div class="py-0.5">
5+
<AnimatedContainer>
6+
{{#animated-if (eq toolCall.status "in_progress") use=this.transition duration=300}}
7+
<div class="text-xs text-gray-300">
8+
<TextShimmer @text={{toolCall.text}} />
9+
</div>
10+
{{else}}
11+
<div class="text-xs text-gray-500">
12+
{{toolCall.text}}
13+
</div>
14+
{{/animated-if}}
15+
</AnimatedContainer>
16+
</div>
17+
{{/animated-each}}
18+
</AnimatedContainer>
19+
</div>
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import Component from '@glimmer/component';
2+
import Logstream from 'codecrafters-frontend/utils/logstream';
3+
import fade from 'ember-animated/transitions/fade';
4+
import move from 'ember-animated/motions/move';
5+
import type ActionCableConsumerService from 'codecrafters-frontend/services/action-cable-consumer';
6+
import type AutofixRequestModel from 'codecrafters-frontend/models/autofix-request';
7+
import type Store from '@ember-data/store';
8+
import { action } from '@ember/object';
9+
import { fadeIn, fadeOut } from 'ember-animated/motions/opacity';
10+
import { next } from '@ember/runloop';
11+
import { service } from '@ember/service';
12+
import { tracked } from '@glimmer/tracking';
13+
14+
interface Signature {
15+
Element: HTMLDivElement;
16+
17+
Args: {
18+
autofixRequest: AutofixRequestModel;
19+
};
20+
}
21+
22+
interface ToolCallStartEvent {
23+
event: 'tool_call_start';
24+
params: {
25+
tool_call_id: string;
26+
tool_name: string;
27+
tool_arguments: Record<string, unknown>;
28+
};
29+
}
30+
31+
interface ToolCallEndEvent {
32+
event: 'tool_call_end';
33+
params: { tool_call_id: string };
34+
}
35+
36+
type Event = ToolCallStartEvent | ToolCallEndEvent;
37+
38+
class ToolCall {
39+
tool_call_id: string;
40+
tool_name: string;
41+
tool_arguments: Record<string, unknown>;
42+
status: 'in_progress' | 'completed';
43+
44+
constructor(tool_call_id: string, tool_name: string, tool_arguments: Record<string, unknown>) {
45+
this.tool_call_id = tool_call_id;
46+
this.tool_name = tool_name;
47+
this.tool_arguments = tool_arguments;
48+
this.status = 'in_progress';
49+
}
50+
51+
get text(): string {
52+
switch (this.tool_name) {
53+
case 'read':
54+
if (this.status === 'in_progress') {
55+
return `Reading ${this.tool_arguments['path']!}`;
56+
} else {
57+
return `Read ${this.tool_arguments['path']!}`;
58+
}
59+
60+
case 'edit':
61+
if (this.status === 'in_progress') {
62+
return `Editing ${this.tool_arguments['path']!}`;
63+
} else {
64+
return `Edited ${this.tool_arguments['path']!}`;
65+
}
66+
67+
case 'write':
68+
if (this.status === 'in_progress') {
69+
return `Creating ${this.tool_arguments['path']!}`;
70+
} else {
71+
return `Created ${this.tool_arguments['path']!}`;
72+
}
73+
74+
case 'delete':
75+
if (this.status === 'in_progress') {
76+
return `Deleting ${this.tool_arguments['path']!}`;
77+
} else {
78+
return `Deleted ${this.tool_arguments['path']!}`;
79+
}
80+
81+
case 'list':
82+
if (this.status === 'in_progress') {
83+
return 'Listing files';
84+
} else {
85+
return 'Listed files';
86+
}
87+
88+
case 'bash':
89+
if (this.status === 'in_progress') {
90+
return 'Running tests';
91+
} else {
92+
return 'Ran tests';
93+
}
94+
95+
// This is a fake tool call that we insert for UI display purposes
96+
case 'analyze':
97+
if (this.status === 'in_progress') {
98+
return 'Analyzing codebase';
99+
} else {
100+
return 'Analyzed codebase';
101+
}
102+
103+
default:
104+
return this.tool_name;
105+
}
106+
}
107+
108+
isAnalysisAction(): boolean {
109+
return this.tool_name === 'analyze' || this.tool_name === 'read' || this.tool_name === 'list';
110+
}
111+
}
112+
113+
export default class LogstreamSection extends Component<Signature> {
114+
transition = fade;
115+
116+
@service declare actionCableConsumer: ActionCableConsumerService;
117+
@service declare store: Store;
118+
119+
@tracked declare logstream: Logstream;
120+
@tracked logstreamContent = '';
121+
@tracked eventsContainer: HTMLDivElement | null = null;
122+
123+
constructor(owner: unknown, args: Signature['Args']) {
124+
super(owner, args);
125+
126+
this.logstream = new Logstream(args.autofixRequest.logstreamId, this.actionCableConsumer, this.store, () => {
127+
this.logstreamContent = this.logstream.content || '';
128+
129+
next(() => {
130+
if (this.eventsContainer) {
131+
this.eventsContainer.scrollTop = this.eventsContainer.scrollHeight;
132+
}
133+
});
134+
});
135+
136+
next(() => {
137+
this.logstream.subscribe();
138+
});
139+
}
140+
141+
get events(): Event[] {
142+
return this.logstream.content
143+
.split('\n')
144+
.map((line) => {
145+
try {
146+
return JSON.parse(line) as Event;
147+
} catch {
148+
return null;
149+
}
150+
})
151+
.filter(Boolean) as Event[];
152+
}
153+
154+
get toolCalls(): ToolCall[] {
155+
const result: ToolCall[] = [];
156+
157+
for (const event of this.events) {
158+
if (event.event === 'tool_call_start') {
159+
result.push(new ToolCall(event.params.tool_call_id, event.params.tool_name, event.params.tool_arguments || {}));
160+
} else if (event.event === 'tool_call_end') {
161+
result.find((toolCall) => toolCall.tool_call_id === event.params.tool_call_id)!.status = 'completed';
162+
}
163+
}
164+
165+
if (result.every((toolCall) => toolCall.isAnalysisAction())) {
166+
// Show an in-progress analysis action if all tool calls are analysis actions.
167+
result.push(new ToolCall('fake-tool-call-id', 'analyze', {}));
168+
} else {
169+
const analysisToolCall = new ToolCall('fake-tool-call-id', 'analyze', {});
170+
analysisToolCall.status = 'completed';
171+
172+
let lastAnalysisActionIndex = -1;
173+
174+
for (let i = result.length - 1; i >= 0; i--) {
175+
if (result[i]!.isAnalysisAction()) {
176+
lastAnalysisActionIndex = i;
177+
break;
178+
}
179+
}
180+
181+
result.splice(lastAnalysisActionIndex + 1, 0, analysisToolCall);
182+
}
183+
184+
return result;
185+
}
186+
187+
@action
188+
handleDidInsertEventsContainer(eventsContainer: HTMLDivElement) {
189+
this.eventsContainer = eventsContainer;
190+
}
191+
192+
// @ts-expect-error ember-animated not typed
193+
// eslint-disable-next-line require-yield
194+
*listTransition({ insertedSprites, keptSprites, removedSprites }) {
195+
for (const sprite of keptSprites) {
196+
move(sprite);
197+
}
198+
199+
for (const sprite of insertedSprites) {
200+
fadeIn(sprite);
201+
}
202+
203+
for (const sprite of removedSprites) {
204+
fadeOut(sprite);
205+
}
206+
}
207+
208+
willDestroy() {
209+
super.willDestroy();
210+
211+
if (this.logstream) {
212+
this.logstream.unsubscribe();
213+
}
214+
}
215+
}
216+
217+
declare module '@glint/environment-ember-loose/registry' {
218+
export default interface Registry {
219+
'CoursePage::TestResultsBar::AutofixRequestCard::LogstreamSection': typeof LogstreamSection;
220+
}
221+
}

app/components/text-shimmer.hbs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{{! template-lint-disable no-inline-styles }}
2+
<span
3+
class="inline-block bg-clip-text text-transparent animate-text-shimmer [background-size:250%_100%] [background-repeat:no-repeat,padding-box] [background-image:linear-gradient(90deg,transparent_calc(50%-var(--shimmer-spread)),#000,transparent_calc(50%+var(--shimmer-spread))),linear-gradient(#a1a1aa,#a1a1aa)] dark:[background-image:linear-gradient(90deg,transparent_calc(50%-var(--shimmer-spread)),#fff,transparent_calc(50%+var(--shimmer-spread))),linear-gradient(#71717a,#71717a)]"
4+
style={{this.style}}
5+
...attributes
6+
>
7+
{{@text}}
8+
</span>

app/components/text-shimmer.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Component from '@glimmer/component';
2+
import { htmlSafe } from '@ember/template';
3+
import type { SafeString } from '@ember/template/-private/handlebars';
4+
5+
interface Signature {
6+
Element: HTMLSpanElement;
7+
8+
Args: {
9+
text: string;
10+
duration?: number;
11+
spread?: number;
12+
};
13+
}
14+
15+
export default class TextShimmer extends Component<Signature> {
16+
get duration(): number {
17+
return this.args.duration ?? 1.5;
18+
}
19+
20+
get dynamicSpread(): number {
21+
return this.args.text.length * this.spread;
22+
}
23+
24+
get spread(): number {
25+
return this.args.spread ?? 2;
26+
}
27+
28+
get style(): SafeString {
29+
return htmlSafe(`--shimmer-duration: ${this.duration}s; --shimmer-spread: ${this.dynamicSpread}px;`);
30+
}
31+
}
32+
33+
declare module '@glint/environment-ember-loose/registry' {
34+
export default interface Registry {
35+
TextShimmer: typeof TextShimmer;
36+
}
37+
}

0 commit comments

Comments
 (0)