Redesign action queue UI with improved status feedback

This commit is contained in:
Torsten Dittmann
2025-05-14 17:34:24 +02:00
parent e708516ea3
commit 2771a0b09a
8 changed files with 261 additions and 164 deletions
+57 -44
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import 'highlight.js/styles/atom-one-light.css';
import type { ParsedItem, Action } from './parser';
import { Badge, Card, Icon, Layout, Tag } from '@appwrite.io/pink-svelte';
import type { ParsedItem } from './parser';
import { Card, Layout, ShimmerText, Spinner, Typography } from '@appwrite.io/pink-svelte';
import Markdown, { type Plugin } from 'svelte-exmarkdown';
import Li from './(markdown)/Li.svelte';
import H1 from './(markdown)/H1.svelte';
@@ -12,7 +12,6 @@
import Ul from './(markdown)/Ul.svelte';
import Ol from './(markdown)/Ol.svelte';
import rehypeHighlight from 'rehype-highlight';
import { IconCheck, IconClock, IconCog } from '@appwrite.io/pink-icons-svelte';
import { queue } from './queue.svelte';
import { synapse } from '../synapse.svelte';
import { filesystem } from '$lib/components/editor/filesystem';
@@ -23,27 +22,20 @@
let { message }: Props = $props();
const tickets = $state([message.group]);
// Process one item from the queue
async function processQueueItem() {
// Try to get a ticket - if none available, return immediately
if (tickets.length === 0 || !message.group) return;
// Atomically get the ticket (pop removes and returns the last item)
const ticket = tickets.pop();
if (!ticket) return;
// Check if there are waiting items in this group
const list = queue.lists[message.group];
if (!list || !list.some((item) => item.status === 'waiting')) {
// No items to process, return the ticket
tickets.push(ticket);
return;
}
// Get the next item to process
const item = queue.dequeue(message.group);
if (!item) {
// No items to dequeue, return the ticket
tickets.push(ticket);
return;
}
@@ -77,38 +69,31 @@
break;
}
// Mark job as done
if (message.group) {
queue.update(message.group, item.id, 'done');
queue.update(message.group, item.id, { status: 'done' });
}
} catch (error) {
console.error('Error processing queue item:', error);
// Mark as done even on error
if (message.group) {
queue.update(message.group, item.id, 'done');
queue.update(message.group, item.id, { status: 'done' });
}
} finally {
// Return the ticket to the pool
tickets.push(ticket);
// Check for more items with a small delay
setTimeout(checkForMoreItems, 100);
}
}
// Check for more items to process
function checkForMoreItems() {
if (!message.group) return;
// Check if there's anything to process and tickets are available
const list = queue.lists[message.group];
if (tickets.length > 0 && list && list.some((item) => item.status === 'waiting')) {
processQueueItem();
}
}
// Reactive effect to watch for changes and trigger processing
$effect(() => {
if (!('type' in message)) return;
if (message.type !== 'actions') return;
@@ -117,14 +102,11 @@
const list = queue.lists[message.group];
if (!list) return;
// Try to process whenever the queue changes if tickets are available
if (tickets.length > 0 && list.some((item) => item.status === 'waiting')) {
processQueueItem();
}
});
// No need for cleanup since tickets are automatically returned in finally block
const plugins: Plugin[] = [
{
rehypePlugin: rehypeHighlight,
@@ -145,28 +127,55 @@
{#if 'type' in message}
{#key message.content}
{#if message.type === 'actions'}
<Card.Base variant="secondary" padding="xs">
{#each message.actions as action}
{@const actionInQueue = queue.lists[action.group].find(
(n) => n.data.id === action.id
)}
{#if actionInQueue}
<Layout.Stack direction="row" gap="xxs" alignItems="center">
{#if actionInQueue.status === 'waiting'}
<Icon icon={IconCog} />
{:else if actionInQueue.status === 'processing'}
<Icon icon={IconClock} />
{:else if actionInQueue.status === 'done'}
<Icon icon={IconCheck} />
{/if}
{#if action.type === 'file'}
<Badge content={action.src} variant="secondary" />
{:else if action.type === 'shell'}
<code>{action.content}</code>
{/if}
</Layout.Stack>
{/if}
{/each}
<Card.Base variant="primary" padding="none">
<Card.Base variant="secondary" padding="xs">
<Layout.Stack
direction="row"
alignItems="center"
justifyContent="space-between">
<Typography.Text variant="m-500">Version 0</Typography.Text>
{#if message.complete}
<Spinner />
{/if}
</Layout.Stack>
</Card.Base>
<div class="actions">
{#each message.actions as action}
{@const actionInQueue = queue.lists[action.group]?.find(
(n) => n.data.id === action.id
)}
{#if actionInQueue}
<Layout.Stack
direction="row"
alignItems="center"
justifyContent="space-between">
{#if action.type === 'file'}
<Typography.Code size="s">{action.src}</Typography.Code>
<Typography.Code size="s">
{#if actionInQueue.status === 'waiting'}
Waiting
{:else if actionInQueue.status === 'processing'}
<ShimmerText>Generating</ShimmerText>
{:else if actionInQueue.status === 'done'}
Generated
{/if}
</Typography.Code>
{:else if action.type === 'shell'}
<Typography.Code size="s">{action.content}</Typography.Code>
<Typography.Code size="s">
{#if actionInQueue.status === 'waiting'}
Waiting
{:else if actionInQueue.status === 'processing'}
<ShimmerText>Running</ShimmerText>
{:else if actionInQueue.status === 'done'}
Finished
{/if}
</Typography.Code>
{/if}
</Layout.Stack>
{/if}
{/each}
</div>
</Card.Base>
{/if}
{/key}
@@ -206,6 +215,10 @@
0px 1.022px 4.089px -1.022px rgba(55, 59, 77, 0.1);
}
.actions {
padding: var(--space-6);
}
:global(pre) {
margin: 0;
}
+127 -89
View File
@@ -1,14 +1,19 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
createStreamParser,
StreamParser,
type ParsedItem,
type Action,
type TextChunk
type TextChunk,
type ActionsContainer
} from './parser';
function expectAction(item: ParsedItem): asserts item is Action {
expect('type' in item).toBe(true);
expect('type' in item && item.type !== 'actions').toBe(true);
}
function expectActionsContainer(item: ParsedItem): asserts item is ActionsContainer {
expect('type' in item && item.type === 'actions').toBe(true);
}
function expectText(item: ParsedItem): asserts item is TextChunk {
expect(!('type' in item) && 'content' in item).toBe(true);
@@ -21,6 +26,43 @@ describe('stream parser', () => {
parser = createStreamParser();
});
afterEach(() => {
vi.clearAllMocks();
});
it('should group multiple actions from the same source in one container', () => {
// This test checks the actions container grouping functionality
// Add multiple actions in separate chunks
parser.chunk('Text before', 'system');
parser.chunk('<action type="file" src="file1.js">code1</action>', 'system');
parser.chunk('<action type="shell">npm install</action>', 'system');
parser.chunk('Text after', 'system');
parser.end();
let items: ParsedItem[] = [];
parser.parsed.subscribe((value) => {
items = value;
})();
expect(items.length).toBe(3);
const [text1, container, text2] = items;
expectText(text1);
expectActionsContainer(container);
expectText(text2);
expect(text1.content).toBe('Text before');
expect(text2.content).toBe('Text after');
// Check that both actions are in the same container
expect(container.actions.length).toBe(2);
expect(container.actions[0].type).toBe('file');
expect(container.actions[0].src).toBe('file1.js');
expect(container.actions[0].content).toBe('code1');
expect(container.actions[1].type).toBe('shell');
expect(container.actions[1].content).toBe('npm install');
});
it('should parse text chunks correctly', () => {
const text = 'Hello, world!';
parser.chunk(text, 'system');
@@ -48,12 +90,14 @@ describe('stream parser', () => {
expect(items.length).toBe(1);
const [item] = items;
expectAction(item);
expect(item.type === 'file').toBe(true);
expect(item.src).toBe('test.js');
expect(item.content).toBe('console.log("test");');
expect(item.complete).toBe(true);
const [container] = items;
expectActionsContainer(container);
expect(container.actions.length).toBe(1);
const action = container.actions[0];
expect(action.type === 'file').toBe(true);
expect(action.src).toBe('test.js');
expect(action.content).toBe('console.log("test");');
expect(action.complete).toBe(true);
});
it('should parse shell actions correctly', () => {
@@ -68,11 +112,13 @@ describe('stream parser', () => {
expect(items.length).toBe(1);
const [item] = items;
expectAction(item);
expect(item.type).toBe('shell');
expect(item.content).toBe('npm install');
expect(item.complete).toBe(true);
const [container] = items;
expectActionsContainer(container);
expect(container.actions.length).toBe(1);
const action = container.actions[0];
expect(action.type).toBe('shell');
expect(action.content).toBe('npm install');
expect(action.complete).toBe(true);
});
it('should handle mixed content correctly', () => {
@@ -86,14 +132,13 @@ describe('stream parser', () => {
})();
expect(items.length).toBe(3);
console.log(items);
const [text1, action, text2] = items;
expectAction(action);
const [text1, container, text2] = items;
expectActionsContainer(container);
expectText(text1);
expectText(text2);
expect(text1.content).toBe('Text before ');
expect(action.type).toBe('shell');
expect(action.content).toBe('npm install');
expect(container.actions[0].type).toBe('shell');
expect(container.actions[0].content).toBe('npm install');
expect(text2.content).toBe(' Text after');
});
@@ -114,15 +159,15 @@ describe('stream parser', () => {
expect(items.length).toBe(3);
const [text1, action, text2] = items;
expectAction(action);
const [text1, container, text2] = items;
expectActionsContainer(container);
expectText(text1);
expectText(text2);
expect(text1.content).toBe('Text before ');
expect(action.type).toBe('file');
expect(action.src).toBe('test.js');
expect(action.content).toBe('console.log("test");');
expect(action.complete).toBe(true);
expect(container.actions[0].type).toBe('file');
expect(container.actions[0].src).toBe('test.js');
expect(container.actions[0].content).toBe('console.log("test");');
expect(container.actions[0].complete).toBe(true);
expect(text2.content).toBe(' Text after');
});
@@ -140,7 +185,10 @@ describe('stream parser', () => {
});
it('should trim newlines correctly from action content', () => {
const text = '<action type="file" src="test.js">\nconsole.log("test");\n</action>';
// This test verifies that only leading and trailing newlines are trimmed
// but internal newlines are preserved
const text =
'<action type="file" src="test.js">\nconsole.log("test1");\nconsole.log("test2");\n</action>';
parser.chunk(text, 'system');
parser.end();
@@ -151,8 +199,10 @@ describe('stream parser', () => {
expect(items.length).toBe(1);
const [item] = items;
expect(item.content).toBe('console.log("test");');
const [container] = items;
expectActionsContainer(container);
// The leading \n and trailing \n should be removed, but the middle \n should be preserved
expect(container.actions[0].content).toBe('console.log("test1");\nconsole.log("test2");');
});
it('should reset parser state correctly', () => {
@@ -184,13 +234,6 @@ APPWRITE_PROJECT_ID=34534534534
<action type="shell">
npm install appwrite
</action>
Some text in between actions.
<action type="file" src="src/lib/appwrite.ts">
import { Client } from 'appwrite';
const client = new Client();
</action>
`;
parser.chunk(complexExample, 'system');
@@ -201,33 +244,21 @@ const client = new Client();
items = value;
})();
expect(items.length).toBe(5);
expect(items.length).toBe(2);
const [text, container] = items;
const [text1, action1, action2, text2, action3] = items;
expectText(text);
expect(text.content.trim()).toBe("Thanks! I'll help you create a full-featured Todo app.");
expectText(text1);
expect(text1.content.trim()).toBe("Thanks! I'll help you create a full-featured Todo app.");
expectAction(action1);
expectActionsContainer(container);
const [action1, action2] = container.actions;
expect(action1.type).toBe('file');
expect(action1.src).toBe('.env');
expect(action1.content).toBe(
'APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1\nAPPWRITE_PROJECT_ID=34534534534'
);
expectAction(action2);
expect(action2.type).toBe('shell');
expect(action2.content).toBe('npm install appwrite');
expectText(text2);
expect(text2.content.trim()).toBe('Some text in between actions.');
expectAction(action3);
expect(action3.type).toBe('file');
expect(action3.src).toBe('src/lib/appwrite.ts');
expect(action3.content).toBe(
"import { Client } from 'appwrite';\nconst client = new Client();"
);
});
it('should handle nested HTML correctly', () => {
@@ -243,10 +274,11 @@ const client = new Client();
expect(items.length).toBe(1);
const [item] = items;
expectAction(item);
expect(item.type).toBe('file');
expect(item.content).toBe('function Component() { return <div>Hello</div>; }');
const [container] = items;
expectActionsContainer(container);
const action = container.actions[0];
expect(action.type).toBe('file');
expect(action.content).toBe('function Component() { return <div>Hello</div>; }');
});
it('should handle partially closed action tags correctly', () => {
@@ -260,10 +292,11 @@ const client = new Client();
})();
expect(items.length).toBe(1);
const [item] = items;
expectAction(item);
expect(item.complete).toBe(true);
expect(item.content).toBe('console.log("test");');
const [container] = items;
expectActionsContainer(container);
const action = container.actions[0];
expect(action.complete).toBe(true);
expect(action.content).toBe('console.log("test");');
});
it('should handle multiple adjacent actions without text between', () => {
@@ -278,14 +311,14 @@ const client = new Client();
items = value;
})();
expect(items.length).toBe(2);
const [action1, action2] = items;
expectAction(action1);
expectAction(action2);
expect(action1.src).toBe('file1.js');
expect(action1.content).toBe('code1');
expect(action2.src).toBe('file2.js');
expect(action2.content).toBe('code2');
expect(items.length).toBe(1);
const [container] = items;
expectActionsContainer(container);
expect(container.actions.length).toBe(2);
expect(container.actions[0].src).toBe('file1.js');
expect(container.actions[0].content).toBe('code1');
expect(container.actions[1].src).toBe('file2.js');
expect(container.actions[1].content).toBe('code2');
});
it('should handle empty action content correctly', () => {
@@ -298,10 +331,11 @@ const client = new Client();
})();
expect(items.length).toBe(1);
const [item] = items;
expectAction(item);
expect(item.content).toBe('');
expect(item.complete).toBe(true);
const [container] = items;
expectActionsContainer(container);
const action = container.actions[0];
expect(action.content).toBe(''); // Empty content should remain exactly empty
expect(action.complete).toBe(true);
});
it('should filter out empty text chunks', () => {
@@ -315,9 +349,10 @@ const client = new Client();
// Only the action should remain, empty text chunks should be filtered
expect(items.length).toBe(1);
const [item] = items;
expectAction(item);
expect(item.content).toBe('command');
const [container] = items;
expectActionsContainer(container);
const action = container.actions[0];
expect(action.content).toBe('command');
});
it('should handle malformed action tags gracefully', () => {
@@ -331,9 +366,10 @@ const client = new Client();
})();
expect(items.length).toBe(1);
const [item] = items;
expectAction(item);
expect(item.content).toBe('content');
const [container] = items;
expectActionsContainer(container);
const action = container.actions[0];
expect(action.content).toBe('content');
});
it('should handle large content streaming properly', () => {
@@ -350,10 +386,10 @@ const client = new Client();
})();
expect(items.length).toBe(2);
const [text, action] = items;
const [text, container] = items;
expectText(text);
expectAction(action);
expect(action.content.length).toBe(1000);
expectActionsContainer(container);
expect(container.actions[0].content.length).toBe(1000);
});
it('should handle end() call with incomplete action', () => {
@@ -367,10 +403,11 @@ const client = new Client();
})();
expect(items.length).toBe(1);
const [item] = items;
expectAction(item);
expect(item.content).toBe('content');
expect(item.complete).toBe(true);
const [container] = items;
expectActionsContainer(container);
const action = container.actions[0];
expect(action.content).toBe('content');
expect(action.complete).toBe(true);
});
it('should mark TextChunks as complete when end() is called', () => {
@@ -401,12 +438,13 @@ const client = new Client();
})();
expect(items.length).toBe(3);
const [text1, action, text2] = items;
const [text1, container, text2] = items;
expectText(text1);
expectAction(action);
expectActionsContainer(container);
expectText(text2);
expect(text1.complete).toBe(true);
expect(action.complete).toBe(true);
expect(container.complete).toBe(true);
expect(container.actions[0].complete).toBe(true);
expect(text2.complete).toBe(true);
});
+7 -4
View File
@@ -26,6 +26,8 @@ export function isActionType(type: string): type is ActionType {
return ['file', 'shell'].includes(type);
}
type ParserEvents = 'complete' | 'chunk';
export type ParsedItem = Action | TextChunk | ActionsContainer;
export class StreamParser {
@@ -41,8 +43,9 @@ export class StreamParser {
private skipCurrentAction = false;
private pendingText = '';
private callbacksEnabled = true;
private callbacks: Record<'complete', Array<(action: Action) => void | Promise<void>>> = {
complete: []
private callbacks: Record<ParserEvents, Array<(action: Action) => void | Promise<void>>> = {
complete: [],
chunk: []
};
/**
* The store of parsed items that can be subscribed to for reactivity
@@ -58,10 +61,10 @@ export class StreamParser {
/**
* Register a callback to be called when an action is completed
* @param event The event to listen for ('complete')
* @param event The event to listen for
* @param callback Function to call when an action is completed
*/
public on(event: 'complete', callback: (action: Action) => void | Promise<void>): void {
public on(event: ParserEvents, callback: (action: Action) => void | Promise<void>): void {
this.callbacks[event].push(callback);
}
+15 -3
View File
@@ -41,10 +41,22 @@ class Queue<T> {
return item;
}
public update(list: keyof Lists<T>, id: symbol, status: Item<T>['status']): void {
public update(
list: keyof Lists<T>,
id: symbol,
value?: {
status?: Item<T>['status'];
data?: T;
}
): void {
const item = this.lists[list].find((item) => item.id === id);
if (item) {
item.status = status;
if (!item) return;
if (value.status) {
item.status = value.status;
}
if (value.data) {
item.data = value.data;
}
}
}
@@ -132,7 +132,6 @@
parser.end();
}
});
parser.on('complete', async (action) => {
queue.enqueue(action.group, action);
});
+10 -2
View File
@@ -129,6 +129,7 @@ export class Synapse {
}[T],
options?: {
timeout?: number;
noReturn?: boolean;
}
): Promise<BaseMessage> {
const requestId = String(Date.now().toString() + ++this.requestCounter);
@@ -140,8 +141,14 @@ export class Synapse {
requestId
};
const response = new Promise<BaseMessage>((resolve, reject) => {
const noReturn = options?.noReturn === true;
const timeout = setTimeout(() => {
reject(new Error('Request timed out'));
if (noReturn) return;
reject(
new Error('Request timed out', {
cause: message.operation
})
);
}, options?.timeout ?? 5000);
const callback = (message: MessageEvent<string>) => {
const response = JSON.parse(message.data);
@@ -161,7 +168,8 @@ export class Synapse {
finish();
}
};
this.ws.addEventListener('message', callback);
if (!noReturn) this.ws.addEventListener('message', callback);
else resolve(null);
});
this.ws.send(JSON.stringify(message));
+34 -16
View File
@@ -33,20 +33,32 @@
domEvent.stopPropagation();
return;
}
synapse.dispatch('terminal', {
operation: 'createCommand',
params: {
command: key
synapse.dispatch(
'terminal',
{
operation: 'createCommand',
params: {
command: key
}
},
{
noReturn: true
}
});
);
});
term.textarea.addEventListener('paste', (e) => {
synapse.dispatch('terminal', {
operation: 'createCommand',
params: {
command: e.clipboardData.getData('text/plain')
synapse.dispatch(
'terminal',
{
operation: 'createCommand',
params: {
command: e.clipboardData.getData('text/plain')
}
},
{
noReturn: true
}
});
);
});
synapse.addEventListener('terminal', ({ message }) => {
const { data } = message;
@@ -57,13 +69,19 @@
if (term.cols === cols && term.rows === rows) return;
if (!Number.isInteger(cols) || !Number.isInteger(rows)) return;
term.resize(cols - 1, rows - 1);
synapse.dispatch('terminal', {
operation: 'updateSize',
params: {
rows: rows - 0.5,
cols: cols - 0.5
synapse.dispatch(
'terminal',
{
operation: 'updateSize',
params: {
rows: rows - 0.5,
cols: cols - 0.5
}
},
{
noReturn: true
}
});
);
}, 50);
const observer = new ResizeObserver(resizer);
@@ -58,12 +58,18 @@
const terminals = new SvelteMap<symbol, Synapse>();
let currentTerminal: symbol = $state(mainTerminalId);
synapse
.dispatch('synapse', {
operation: 'updateWorkDir',
params: {
workDir: `/artifact/${page.params.artifact}`
.dispatch(
'synapse',
{
operation: 'updateWorkDir',
params: {
workDir: `/artifact/${page.params.artifact}`
}
},
{
noReturn: true
}
})
)
.then(() => {
return synapse.dispatch('fs', {
operation: 'getFolder',