Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions app/components/chat/LiveActionAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useStore } from '@nanostores/react';
import { memo, useEffect, useRef } from 'react';
import { workbenchStore } from '~/lib/stores/workbench';
import { motion, AnimatePresence } from 'framer-motion';
import { classNames } from '~/utils/classNames';

export const LiveActionAlert = memo(() => {
const alert = useStore(workbenchStore.actionAlert);
const outputRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (outputRef.current && alert?.isStreaming) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [alert?.streamingOutput]);

if (!alert || !alert.isStreaming) {
return null;
}

return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="fixed bottom-4 right-4 w-96 max-h-80 bg-codinit-elements-background-depth-1
border border-codinit-elements-borderColor rounded-lg shadow-2xl z-50
flex flex-col overflow-hidden"
>
<div className="p-3 border-b border-codinit-elements-borderColor flex items-center justify-between bg-codinit-elements-background-depth-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
className="flex-shrink-0"
>
<div className="i-svg-spinners:90-ring-with-bg text-blue-500 text-lg" />
</motion.div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-codinit-elements-textPrimary truncate">{alert.title}</div>
{alert.command && (
<div className="text-xs text-codinit-elements-textSecondary truncate font-mono">{alert.command}</div>
)}
</div>
</div>
<button
onClick={() => workbenchStore.actionAlert.set(undefined)}
className={classNames(
'flex-shrink-0 ml-2 p-1 rounded hover:bg-codinit-elements-background-depth-3',
'text-codinit-elements-textSecondary hover:text-codinit-elements-textPrimary',
'transition-colors',
)}
title="Close"
>
<div className="i-ph:x text-sm" />
</button>
</div>

<div
ref={outputRef}
className="flex-1 p-3 overflow-y-auto overflow-x-hidden font-mono text-xs
text-codinit-elements-textPrimary bg-codinit-elements-background-depth-1
scrollbar-thin scrollbar-thumb-codinit-elements-borderColor
scrollbar-track-transparent"
>
<pre className="whitespace-pre-wrap break-words">{alert.streamingOutput || alert.content}</pre>
</div>

{alert.progress !== undefined && alert.progress >= 0 && (
<div className="p-2 border-t border-codinit-elements-borderColor bg-codinit-elements-background-depth-2">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-codinit-elements-textSecondary">Progress</span>
<span className="text-xs font-medium text-codinit-elements-textPrimary">
{Math.round(alert.progress)}%
</span>
</div>
<div className="h-1 bg-codinit-elements-background-depth-3 rounded-full overflow-hidden">
<motion.div
className="h-full bg-blue-500"
initial={{ width: 0 }}
animate={{ width: `${alert.progress}%` }}
transition={{ duration: 0.3, ease: 'easeOut' }}
/>
</div>
</div>
)}
</motion.div>
</AnimatePresence>
);
});

LiveActionAlert.displayName = 'LiveActionAlert';
4 changes: 2 additions & 2 deletions app/components/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export function Header() {
</div>
<div className="flex items-center gap-2">
<a href="https://fazier.com/launches/codinit" target="_blank">
<img src="/embed_image_dark.svg" alt="Fazier badge" className="h-8 hidden dark:block" />
<img src="/embed_image_light.svg" alt="Fazier badge" className="h-8 block dark:hidden" />
<img src="/rank-2-dark.svg" alt="Fazier badge" className="h-8 hidden dark:block" />
<img src="/rank-2-light.svg" alt="Fazier badge" className="h-8 block dark:hidden" />
</a>
<button
onClick={() => window.open('https://github.com/codinit-dev/codinit-dev/issues/new/choose', '_blank')}
Expand Down
29 changes: 29 additions & 0 deletions app/lib/runtime/action-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export class ActionRunner {
onSupabaseAlert?: (alert: SupabaseAlert) => void;
onDeployAlert?: (alert: DeployAlert) => void;
onTestResult?: TestResultCallback;
onLiveOutput?: (output: string, actionId: string) => void;
buildOutput?: { path: string; exitCode: number; output: string };

constructor(
Expand All @@ -92,13 +93,15 @@ export class ActionRunner {
onSupabaseAlert?: (alert: SupabaseAlert) => void,
onDeployAlert?: (alert: DeployAlert) => void,
onTestResult?: TestResultCallback,
onLiveOutput?: (output: string, actionId: string) => void,
) {
this.#webcontainer = webcontainerPromise;
this.#shellTerminal = getShellTerminal;
this.onAlert = onAlert;
this.onSupabaseAlert = onSupabaseAlert;
this.onDeployAlert = onDeployAlert;
this.onTestResult = onTestResult;
this.onLiveOutput = onLiveOutput;
}

addAction(data: ActionCallbackData) {
Expand Down Expand Up @@ -272,6 +275,10 @@ export class ActionRunner {
unreachable('Shell terminal not found');
}

if (this.onLiveOutput && shell.liveActionStream) {
this.#monitorLiveOutput(shell.liveActionStream, action.content);
}

const resp = await shell.executeCommand(this.runnerId.get(), action.content, () => {
logger.debug(`[${action.type}]:Aborting Action\n\n`, action);
action.abort();
Expand All @@ -298,6 +305,28 @@ export class ActionRunner {
}
}

async #monitorLiveOutput(stream: ReadableStreamDefaultReader<string>, command: string) {
let buffer = '';

try {
while (true) {
const { value, done } = await stream.read();

if (done) {
break;
}

buffer += value || '';

if (this.onLiveOutput) {
this.onLiveOutput(buffer, command);
}
}
} catch (error) {
logger.error('Live output monitoring error:', error);
}
}

async #runStartAction(action: ActionState) {
if (action.type !== 'start') {
unreachable('Expected shell action');
Expand Down
22 changes: 22 additions & 0 deletions app/lib/stores/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import Cookies from 'js-cookie';
import { createSampler } from '~/utils/sampler';
import type { ActionAlert, DeployAlert, SupabaseAlert } from '~/types/actions';
import { startAutoSave } from '~/lib/persistence/fileAutoSave';
import { liveActionConsoleStore } from './settings';

const { saveAs } = fileSaver;

Expand Down Expand Up @@ -597,6 +598,27 @@ export class WorkbenchStore {
});
}
},
(output, command) => {
if (this.#reloadedMessages.has(messageId)) {
return;
}

const liveConsoleEnabled = liveActionConsoleStore.get();

if (!liveConsoleEnabled) {
return;
}

this.actionAlert.set({
type: 'info',
title: 'Command Running',
description: `Executing: ${command}`,
content: output,
isStreaming: true,
streamingOutput: output,
command,
});
},
),
});
}
Expand Down
2 changes: 2 additions & 0 deletions app/routes/_index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Chat } from '~/components/chat/Chat.client';
import { Header } from '~/components/header/Header';
import { ElectronTitleBar } from '~/components/ui/ElectronTitleBar';
import BackgroundRays from '~/components/ui/BackgroundRays';
import { LiveActionAlert } from '~/components/chat/LiveActionAlert';

export const meta: MetaFunction = () => {
return [
Expand All @@ -22,6 +23,7 @@ export default function Index() {
<BackgroundRays />
<Header />
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
<ClientOnly>{() => <LiveActionAlert />}</ClientOnly>
</div>
);
}
24 changes: 20 additions & 4 deletions app/utils/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export class ExampleShell {
>();
#outputStream: ReadableStreamDefaultReader<string> | undefined;
#shellInputStream: WritableStreamDefaultWriter<string> | undefined;
#liveActionStream: ReadableStreamDefaultReader<string> | undefined;

constructor() {
this.#readyPromise = new Promise((resolve) => {
Expand All @@ -77,14 +78,22 @@ export class ExampleShell {
return this.#readyPromise;
}

get liveActionStream() {
return this.#liveActionStream;
}

async init(webcontainer: WebContainer, terminal: ITerminal) {
this.#webcontainer = webcontainer;
this.#terminal = terminal;

// Use all three streams from tee: one for terminal, one for command execution, one for Expo URL detection
const { process, commandStream, expoUrlStream } = await this.newExampleShellProcess(webcontainer, terminal);
// Use all four streams from tee: terminal, command execution, Expo URL detection, live action monitoring
const { process, commandStream, expoUrlStream, liveActionStream } = await this.newExampleShellProcess(
webcontainer,
terminal,
);
this.#process = process;
this.#outputStream = commandStream.getReader();
this.#liveActionStream = liveActionStream.getReader();

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

// Tee the output so we can have three independent readers
// Tee the output so we can have four independent readers
const [streamA, streamB] = process.output.tee();
const [streamC, streamD] = streamB.tee();
const [streamE, streamF] = streamD.tee();

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

// Return all streams for use in init
return { process, terminalStream: streamA, commandStream: streamC, expoUrlStream: streamD };
return {
process,
terminalStream: streamA,
commandStream: streamC,
expoUrlStream: streamE,
liveActionStream: streamF,
};
}

// Dedicated background watcher for Expo URL
Expand Down
Binary file added public/hero.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading