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
4 changes: 4 additions & 0 deletions desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,8 @@ export function App() {
<DashboardScreen
key="dashboard"
copy={copy}
language={language}
theme={theme}
installDir={runtimeInstallDir}
runtimeView={runtimeView}
updateAvailable={updateAvailable}
Expand All @@ -1133,6 +1135,8 @@ export function App() {
onRestart={() => void restartInstalledOctopal()}
onUpdateOctopal={() => void updateInstalledOctopal()}
onUpdateDesktopApp={() => void updateDesktopApp()}
onLanguageChange={updateLanguage}
onThemeChange={setTheme}
/>
) : null}

Expand Down
22 changes: 1 addition & 21 deletions desktop/src/renderer/src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1 @@
import { type ButtonHTMLAttributes, type ReactNode } from "react";

import { cn } from "../lib/cn";

type ButtonVariant = "primary" | "secondary" | "ghost" | "success" | "danger";

export function Button({
children,
className,
variant = "primary",
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
children: ReactNode;
variant?: ButtonVariant;
}) {
return (
<button className={cn("button", `button-${variant}`, className)} {...props}>
{children}
</button>
);
}
export { Button } from "./ui/button";
170 changes: 120 additions & 50 deletions desktop/src/renderer/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ const initialStatus: DesktopChatConnectionStatus = {
detail: "Chat is idle.",
};

const ACTIVITY_TIMEOUT_MS = 30_000;
const THINKING_STATUS_TEXT = "Octo is thinking";

function stringValue(value: unknown, fallback = ""): string {
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
Expand Down Expand Up @@ -154,42 +157,58 @@ function workerSnapshotText(worker: Record<string, unknown>): string {
const name = workerSnapshotName(worker);
const status = stringValue(worker.status, "unknown").toLowerCase();
if (status === "running") {
return `${name} worker is running.`;
return `${name} worker is running`;
}
if (status === "waiting_for_children") {
return `${name} worker is waiting for child workers.`;
return `${name} worker is waiting for child workers`;
}
if (status === "awaiting_instruction") {
return `${name} worker is awaiting instruction.`;
return `${name} worker is awaiting instruction`;
}
if (["started", "completed", "failed", "stopped"].includes(status)) {
return `${name} worker ${status}.`;
return `${name} worker ${status}`;
}
return `${name} worker status: ${status}.`;
return `${name} worker status: ${status}`;
}

function activityStatusText(text: string): string {
return text.trim().replace(/\.+$/u, "");
}

function chatItemFromWorkerSnapshot(
function activityTextFromWorkerSnapshot(
worker: Record<string, unknown>,
index: number,
): ChatItem {
const createdAt =
stringValue(worker.updated_at) ||
stringValue(worker.created_at) ||
new Date().toISOString();
const workerId = stringValue(worker.id, `worker-${index}`);
const status = stringValue(worker.status, "unknown");
return {
id: `worker-${workerId}-${status}-${createdAt}`,
kind: "event",
type: "worker_snapshot",
role: "system",
direction: "event",
channel: "runtime",
text: workerSnapshotText(worker),
createdAt,
meta: worker,
technical: true,
};
): string {
return workerSnapshotText(worker);
}

function activityTextFromEvent(event: DesktopChatEvent): string {
const type = stringValue(event.type);
if (type === "progress") {
return eventText(event);
}
if (type === "worker_event") {
const payload = recordValue(event.payload);
return (
stringValue(event.text) ||
stringValue(event.message) ||
stringValue(payload.message) ||
stringValue(payload.summary) ||
stringValue(event.event)
);
}
if (type === "workers_snapshot") {
const activeWorker = recordArray(event.workers).find((worker) => {
const status = stringValue(worker.status).toLowerCase();
return [
"running",
"started",
"waiting_for_children",
"awaiting_instruction",
].includes(status);
});
return activeWorker ? activityTextFromWorkerSnapshot(activeWorker) : "";
}
return "";
}

function chatItemFromEvent(
Expand Down Expand Up @@ -235,15 +254,19 @@ function chatItemFromEvent(
};
}

if (["workers_snapshot", "pong", "typing", "worker_event"].includes(type)) {
if (
["workers_snapshot", "pong", "typing", "worker_event", "progress"].includes(
type,
)
) {
return null;
}

if (type === "warning" && isWebSocketTakeoverNotice(text)) {
return null;
}

if (["progress", "file", "warning", "error"].includes(type)) {
if (["file", "warning", "error"].includes(type)) {
return {
id: baseId,
kind: "event",
Expand Down Expand Up @@ -276,9 +299,7 @@ function chatItemsFromEvent(
.filter((item): item is ChatItem => item !== null);
}
if (type === "workers_snapshot") {
return recordArray(event.workers).map((worker, workerIndex) =>
chatItemFromWorkerSnapshot(worker, index + workerIndex),
);
return [];
}
const item = chatItemFromEvent(event, index);
return item ? [item] : [];
Expand Down Expand Up @@ -377,8 +398,10 @@ export function ChatView({ active, installDir }: ChatViewProps) {
const [sendError, setSendError] = useState("");
const [sending, setSending] = useState(false);
const [thinking, setThinking] = useState(false);
const [activityText, setActivityText] = useState("");
const scrollRef = useRef<HTMLDivElement | null>(null);
const eventCount = useRef(0);
const activityTimeoutRef = useRef<number | null>(null);

const connected = status.state === "connected";
const canSend =
Expand Down Expand Up @@ -412,6 +435,37 @@ export function ChatView({ active, installDir }: ChatViewProps) {
}
}, [installDir]);

const clearActivityTimeout = useCallback(() => {
if (activityTimeoutRef.current !== null) {
window.clearTimeout(activityTimeoutRef.current);
activityTimeoutRef.current = null;
}
}, []);

const clearActivity = useCallback(() => {
clearActivityTimeout();
setThinking(false);
setActivityText("");
}, [clearActivityTimeout]);

const scheduleActivityTimeout = useCallback(() => {
clearActivityTimeout();
activityTimeoutRef.current = window.setTimeout(() => {
setThinking(false);
setActivityText("");
activityTimeoutRef.current = null;
}, ACTIVITY_TIMEOUT_MS);
}, [clearActivityTimeout]);

const showActivity = useCallback(
(text: string) => {
setActivityText(activityStatusText(text) || THINKING_STATUS_TEXT);
setThinking(true);
scheduleActivityTimeout();
},
[scheduleActivityTimeout],
);

useEffect(() => {
if (!window.octopalDesktop || !installDir) {
return;
Expand All @@ -420,7 +474,13 @@ export function ChatView({ active, installDir }: ChatViewProps) {
const unsubscribeStatus = window.octopalDesktop.onChatStatus(setStatus);
const unsubscribeEvent = window.octopalDesktop.onChatEvent((event) => {
if (stringValue(event.type) === "typing") {
setThinking(Boolean(event.active));
if (event.active) {
setActivityText((current) => current || THINKING_STATUS_TEXT);
setThinking(true);
scheduleActivityTimeout();
} else {
setThinking(false);
}
return;
}

Expand Down Expand Up @@ -457,12 +517,20 @@ export function ChatView({ active, installDir }: ChatViewProps) {
}

eventCount.current += 1;
const activity = activityTextFromEvent(event);
if (activity) {
showActivity(activity);
}
const nextItems = chatItemsFromEvent(event, eventCount.current);
if (nextItems.length === 0) {
return;
}
if (nextItems.some((item) => item.role === "assistant" || item.type === "error")) {
setThinking(false);
if (
nextItems.some(
(item) => item.role === "assistant" || item.type === "error",
)
) {
clearActivity();
}
setItems((current) => {
if (
Expand All @@ -487,8 +555,16 @@ export function ChatView({ active, installDir }: ChatViewProps) {
return () => {
unsubscribeStatus();
unsubscribeEvent();
clearActivityTimeout();
};
}, [connect, installDir]);
}, [
clearActivity,
clearActivityTimeout,
connect,
installDir,
scheduleActivityTimeout,
showActivity,
]);

useEffect(() => {
if (!active) {
Expand All @@ -498,7 +574,7 @@ export function ChatView({ active, installDir }: ChatViewProps) {
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}, [active, sortedItems.length, thinking]);
}, [active, sortedItems.length, thinking, activityText]);

function appendAttachments(next: DesktopChatAttachment[]): void {
setAttachments((current) => {
Expand Down Expand Up @@ -580,7 +656,7 @@ export function ChatView({ active, installDir }: ChatViewProps) {
);
setDraft("");
setAttachments([]);
setThinking(true);
showActivity(THINKING_STATUS_TEXT);
} catch (error) {
setThinking(false);
setSendError(
Expand Down Expand Up @@ -626,7 +702,7 @@ export function ChatView({ active, installDir }: ChatViewProps) {
aria-label="Desktop chat"
>
<div ref={scrollRef} className="chat-transcript">
{sortedItems.length === 0 ? (
{sortedItems.length === 0 && !activityText && !thinking ? (
<div className="chat-empty">
<h2>No chat events yet</h2>
<p>Waiting for live activity.</p>
Expand Down Expand Up @@ -698,18 +774,12 @@ export function ChatView({ active, installDir }: ChatViewProps) {
) : null}
</article>
))}
{thinking ? (
<article className="chat-bubble chat-bubble-assistant chat-thinking">
<div className="chat-item-meta">
<span>Octo</span>
<span>thinking</span>
</div>
<div className="chat-thinking-dots" aria-label="Octo is thinking">
<span />
<span />
<span />
</div>
</article>
{thinking || activityText ? (
<div className="chat-activity" aria-live="polite">
<span className="chat-activity-text">
{activityText || THINKING_STATUS_TEXT}
</span>
</div>
) : null}
</div>

Expand Down
Loading