Skip to content

Commit 763622b

Browse files
Merge pull request codinit-dev#49 from codinit-dev/feature/live-action-console
Feature/live action console
2 parents e49fc26 + fe92b19 commit 763622b

9 files changed

Lines changed: 265 additions & 6 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { useStore } from '@nanostores/react';
2+
import { memo, useEffect, useRef } from 'react';
3+
import { workbenchStore } from '~/lib/stores/workbench';
4+
import { motion, AnimatePresence } from 'framer-motion';
5+
import { classNames } from '~/utils/classNames';
6+
7+
export const LiveActionAlert = memo(() => {
8+
const alert = useStore(workbenchStore.actionAlert);
9+
const outputRef = useRef<HTMLDivElement>(null);
10+
11+
useEffect(() => {
12+
if (outputRef.current && alert?.isStreaming) {
13+
outputRef.current.scrollTop = outputRef.current.scrollHeight;
14+
}
15+
}, [alert?.streamingOutput]);
16+
17+
if (!alert || !alert.isStreaming) {
18+
return null;
19+
}
20+
21+
return (
22+
<AnimatePresence>
23+
<motion.div
24+
initial={{ opacity: 0, y: 20, scale: 0.95 }}
25+
animate={{ opacity: 1, y: 0, scale: 1 }}
26+
exit={{ opacity: 0, y: 20, scale: 0.95 }}
27+
transition={{ duration: 0.2, ease: 'easeOut' }}
28+
className="fixed bottom-4 right-4 w-96 max-h-80 bg-codinit-elements-background-depth-1
29+
border border-codinit-elements-borderColor rounded-lg shadow-2xl z-50
30+
flex flex-col overflow-hidden"
31+
>
32+
<div className="p-3 border-b border-codinit-elements-borderColor flex items-center justify-between bg-codinit-elements-background-depth-2">
33+
<div className="flex items-center gap-2 flex-1 min-w-0">
34+
<motion.div
35+
animate={{ rotate: 360 }}
36+
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
37+
className="flex-shrink-0"
38+
>
39+
<div className="i-svg-spinners:90-ring-with-bg text-blue-500 text-lg" />
40+
</motion.div>
41+
<div className="flex-1 min-w-0">
42+
<div className="font-medium text-sm text-codinit-elements-textPrimary truncate">{alert.title}</div>
43+
{alert.command && (
44+
<div className="text-xs text-codinit-elements-textSecondary truncate font-mono">{alert.command}</div>
45+
)}
46+
</div>
47+
</div>
48+
<button
49+
onClick={() => workbenchStore.actionAlert.set(undefined)}
50+
className={classNames(
51+
'flex-shrink-0 ml-2 p-1 rounded hover:bg-codinit-elements-background-depth-3',
52+
'text-codinit-elements-textSecondary hover:text-codinit-elements-textPrimary',
53+
'transition-colors',
54+
)}
55+
title="Close"
56+
>
57+
<div className="i-ph:x text-sm" />
58+
</button>
59+
</div>
60+
61+
<div
62+
ref={outputRef}
63+
className="flex-1 p-3 overflow-y-auto overflow-x-hidden font-mono text-xs
64+
text-codinit-elements-textPrimary bg-codinit-elements-background-depth-1
65+
scrollbar-thin scrollbar-thumb-codinit-elements-borderColor
66+
scrollbar-track-transparent"
67+
>
68+
<pre className="whitespace-pre-wrap break-words">{alert.streamingOutput || alert.content}</pre>
69+
</div>
70+
71+
{alert.progress !== undefined && alert.progress >= 0 && (
72+
<div className="p-2 border-t border-codinit-elements-borderColor bg-codinit-elements-background-depth-2">
73+
<div className="flex items-center justify-between mb-1">
74+
<span className="text-xs text-codinit-elements-textSecondary">Progress</span>
75+
<span className="text-xs font-medium text-codinit-elements-textPrimary">
76+
{Math.round(alert.progress)}%
77+
</span>
78+
</div>
79+
<div className="h-1 bg-codinit-elements-background-depth-3 rounded-full overflow-hidden">
80+
<motion.div
81+
className="h-full bg-blue-500"
82+
initial={{ width: 0 }}
83+
animate={{ width: `${alert.progress}%` }}
84+
transition={{ duration: 0.3, ease: 'easeOut' }}
85+
/>
86+
</div>
87+
</div>
88+
)}
89+
</motion.div>
90+
</AnimatePresence>
91+
);
92+
});
93+
94+
LiveActionAlert.displayName = 'LiveActionAlert';

app/components/header/Header.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ export function Header() {
3232
</div>
3333
<div className="flex items-center gap-2">
3434
<a href="https://fazier.com/launches/codinit" target="_blank">
35-
<img src="/embed_image_dark.svg" alt="Fazier badge" className="h-8 hidden dark:block" />
36-
<img src="/embed_image_light.svg" alt="Fazier badge" className="h-8 block dark:hidden" />
35+
<img src="/rank-2-dark.svg" alt="Fazier badge" className="h-8 hidden dark:block" />
36+
<img src="/rank-2-light.svg" alt="Fazier badge" className="h-8 block dark:hidden" />
3737
</a>
3838
<button
3939
onClick={() => window.open('https://github.com/codinit-dev/codinit-dev/issues/new/choose', '_blank')}

app/lib/runtime/action-runner.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export class ActionRunner {
8383
onSupabaseAlert?: (alert: SupabaseAlert) => void;
8484
onDeployAlert?: (alert: DeployAlert) => void;
8585
onTestResult?: TestResultCallback;
86+
onLiveOutput?: (output: string, actionId: string) => void;
8687
buildOutput?: { path: string; exitCode: number; output: string };
8788

8889
constructor(
@@ -92,13 +93,15 @@ export class ActionRunner {
9293
onSupabaseAlert?: (alert: SupabaseAlert) => void,
9394
onDeployAlert?: (alert: DeployAlert) => void,
9495
onTestResult?: TestResultCallback,
96+
onLiveOutput?: (output: string, actionId: string) => void,
9597
) {
9698
this.#webcontainer = webcontainerPromise;
9799
this.#shellTerminal = getShellTerminal;
98100
this.onAlert = onAlert;
99101
this.onSupabaseAlert = onSupabaseAlert;
100102
this.onDeployAlert = onDeployAlert;
101103
this.onTestResult = onTestResult;
104+
this.onLiveOutput = onLiveOutput;
102105
}
103106

104107
addAction(data: ActionCallbackData) {
@@ -272,6 +275,10 @@ export class ActionRunner {
272275
unreachable('Shell terminal not found');
273276
}
274277

278+
if (this.onLiveOutput && shell.liveActionStream) {
279+
this.#monitorLiveOutput(shell.liveActionStream, action.content);
280+
}
281+
275282
const resp = await shell.executeCommand(this.runnerId.get(), action.content, () => {
276283
logger.debug(`[${action.type}]:Aborting Action\n\n`, action);
277284
action.abort();
@@ -298,6 +305,28 @@ export class ActionRunner {
298305
}
299306
}
300307

308+
async #monitorLiveOutput(stream: ReadableStreamDefaultReader<string>, command: string) {
309+
let buffer = '';
310+
311+
try {
312+
while (true) {
313+
const { value, done } = await stream.read();
314+
315+
if (done) {
316+
break;
317+
}
318+
319+
buffer += value || '';
320+
321+
if (this.onLiveOutput) {
322+
this.onLiveOutput(buffer, command);
323+
}
324+
}
325+
} catch (error) {
326+
logger.error('Live output monitoring error:', error);
327+
}
328+
}
329+
301330
async #runStartAction(action: ActionState) {
302331
if (action.type !== 'start') {
303332
unreachable('Expected shell action');

app/lib/stores/workbench.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import Cookies from 'js-cookie';
2323
import { createSampler } from '~/utils/sampler';
2424
import type { ActionAlert, DeployAlert, SupabaseAlert } from '~/types/actions';
2525
import { startAutoSave } from '~/lib/persistence/fileAutoSave';
26+
import { liveActionConsoleStore } from './settings';
2627

2728
const { saveAs } = fileSaver;
2829

@@ -597,6 +598,27 @@ export class WorkbenchStore {
597598
});
598599
}
599600
},
601+
(output, command) => {
602+
if (this.#reloadedMessages.has(messageId)) {
603+
return;
604+
}
605+
606+
const liveConsoleEnabled = liveActionConsoleStore.get();
607+
608+
if (!liveConsoleEnabled) {
609+
return;
610+
}
611+
612+
this.actionAlert.set({
613+
type: 'info',
614+
title: 'Command Running',
615+
description: `Executing: ${command}`,
616+
content: output,
617+
isStreaming: true,
618+
streamingOutput: output,
619+
command,
620+
});
621+
},
600622
),
601623
});
602624
}

app/routes/_index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Chat } from '~/components/chat/Chat.client';
55
import { Header } from '~/components/header/Header';
66
import { ElectronTitleBar } from '~/components/ui/ElectronTitleBar';
77
import BackgroundRays from '~/components/ui/BackgroundRays';
8+
import { LiveActionAlert } from '~/components/chat/LiveActionAlert';
89

910
export const meta: MetaFunction = () => {
1011
return [
@@ -22,6 +23,7 @@ export default function Index() {
2223
<BackgroundRays />
2324
<Header />
2425
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
26+
<ClientOnly>{() => <LiveActionAlert />}</ClientOnly>
2527
</div>
2628
);
2729
}

app/utils/shell.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export class ExampleShell {
6666
>();
6767
#outputStream: ReadableStreamDefaultReader<string> | undefined;
6868
#shellInputStream: WritableStreamDefaultWriter<string> | undefined;
69+
#liveActionStream: ReadableStreamDefaultReader<string> | undefined;
6970

7071
constructor() {
7172
this.#readyPromise = new Promise((resolve) => {
@@ -77,14 +78,22 @@ export class ExampleShell {
7778
return this.#readyPromise;
7879
}
7980

81+
get liveActionStream() {
82+
return this.#liveActionStream;
83+
}
84+
8085
async init(webcontainer: WebContainer, terminal: ITerminal) {
8186
this.#webcontainer = webcontainer;
8287
this.#terminal = terminal;
8388

84-
// Use all three streams from tee: one for terminal, one for command execution, one for Expo URL detection
85-
const { process, commandStream, expoUrlStream } = await this.newExampleShellProcess(webcontainer, terminal);
89+
// Use all four streams from tee: terminal, command execution, Expo URL detection, live action monitoring
90+
const { process, commandStream, expoUrlStream, liveActionStream } = await this.newExampleShellProcess(
91+
webcontainer,
92+
terminal,
93+
);
8694
this.#process = process;
8795
this.#outputStream = commandStream.getReader();
96+
this.#liveActionStream = liveActionStream.getReader();
8897

8998
// Start background Expo URL watcher immediately
9099
this._watchExpoUrlInBackground(expoUrlStream);
@@ -105,9 +114,10 @@ export class ExampleShell {
105114
const input = process.input.getWriter();
106115
this.#shellInputStream = input;
107116

108-
// Tee the output so we can have three independent readers
117+
// Tee the output so we can have four independent readers
109118
const [streamA, streamB] = process.output.tee();
110119
const [streamC, streamD] = streamB.tee();
120+
const [streamE, streamF] = streamD.tee();
111121

112122
const jshReady = withResolvers<void>();
113123
let isInteractive = false;
@@ -137,7 +147,13 @@ export class ExampleShell {
137147
await jshReady.promise;
138148

139149
// Return all streams for use in init
140-
return { process, terminalStream: streamA, commandStream: streamC, expoUrlStream: streamD };
150+
return {
151+
process,
152+
terminalStream: streamA,
153+
commandStream: streamC,
154+
expoUrlStream: streamE,
155+
liveActionStream: streamF,
156+
};
141157
}
142158

143159
// Dedicated background watcher for Expo URL

public/hero.png

518 KB
Loading

0 commit comments

Comments
 (0)