From 47f3948aec77371fc3cf6aaa23b8344284a11d5f Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 9 Nov 2025 02:06:05 +0000 Subject: [PATCH] improve instance launch and log streaming UX --- src/App.tsx | 6 +- src/components/folder-selection-view.tsx | 82 ++++++++++++++++++----- src/components/info-view.tsx | 63 +++++++++++++----- src/components/logs-view.tsx | 59 +++++++++++++---- src/stores/instances.ts | 43 ++++++++++++ src/stores/sessions.ts | 24 ++----- src/styles/components.css | 84 ++++++++++++++++++++++++ 7 files changed, 296 insertions(+), 65 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7e27827a..5b6de067 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,6 +56,7 @@ import { agents, isSessionBusy, getSessionInfo, + isSessionMessagesLoading, } from "./stores/sessions" import { setupTabKeyboardShortcuts } from "./lib/keyboard" import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette } from "./stores/command-palette" @@ -74,8 +75,10 @@ const SessionView: Component<{ escapeInDebounce: boolean }> = (props) => { const session = () => props.activeSessions.get(props.sessionId) - + const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId)) + createEffect(() => { + const currentSession = session() if (currentSession) { loadMessages(props.instanceId, currentSession.id).catch(console.error) @@ -186,6 +189,7 @@ const SessionView: Component<{ messages={s().messages || []} messagesInfo={s().messagesInfo} revert={s().revert} + loading={messagesLoading()} onRevert={handleRevert} onFork={handleFork} /> diff --git a/src/components/folder-selection-view.tsx b/src/components/folder-selection-view.tsx index 5752239a..bfb39ed1 100644 --- a/src/components/folder-selection-view.tsx +++ b/src/components/folder-selection-view.tsx @@ -18,15 +18,17 @@ const FolderSelectionView: Component = (props) => { let recentListRef: HTMLDivElement | undefined const folders = () => recentFolders() - + const isLoading = () => Boolean(props.isLoading) + // Update selected binary when preferences change createEffect(() => { - const lastUsed = preferences().lastUsedBinary - if (lastUsed && lastUsed !== selectedBinary()) { - setSelectedBinary(lastUsed) - } - }) - + const lastUsed = preferences().lastUsedBinary + if (lastUsed && lastUsed !== selectedBinary()) { + setSelectedBinary(lastUsed) + } + }) + + function scrollToIndex(index: number) { const container = recentListRef if (!container) return @@ -45,16 +47,36 @@ const FolderSelectionView: Component = (props) => { function handleKeyDown(e: KeyboardEvent) { + const normalizedKey = e.key.toLowerCase() + const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n" + const blockedKeys = [ + "ArrowDown", + "ArrowUp", + "PageDown", + "PageUp", + "Home", + "End", + "Enter", + "Backspace", + "Delete", + ] + + if (isLoading()) { + if (isBrowseShortcut || blockedKeys.includes(e.key)) { + e.preventDefault() + } + return + } + const folderList = folders() - - if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "n") { + + if (isBrowseShortcut) { e.preventDefault() handleBrowse() return } - - if (folderList.length === 0) return + if (folderList.length === 0) return if (e.key === "ArrowDown") { e.preventDefault() @@ -107,15 +129,19 @@ const FolderSelectionView: Component = (props) => { } } + function handleEnterKey() { + if (isLoading()) return const folderList = folders() const index = selectedIndex() - if (index < folderList.length) { - props.onSelectFolder(folderList[index].path) + const folder = folderList[index] + if (folder) { + handleFolderSelect(folder.path) } } + onMount(() => { window.addEventListener("keydown", handleKeyDown) onCleanup(() => { @@ -136,20 +162,24 @@ const FolderSelectionView: Component = (props) => { } function handleFolderSelect(path: string) { + if (isLoading()) return updateLastUsedBinary(selectedBinary()) props.onSelectFolder(path, selectedBinary()) } - + function handleBrowse() { + if (isLoading()) return updateLastUsedBinary(selectedBinary()) props.onSelectFolder(undefined, selectedBinary()) } + function handleBinaryChange(binary: string) { setSelectedBinary(binary) } function handleRemove(path: string, e?: Event) { + if (isLoading()) return e?.stopPropagation() removeRecentFolder(path) @@ -159,6 +189,7 @@ const FolderSelectionView: Component = (props) => { } } + function getDisplayPath(path: string): string { if (path.startsWith("/Users/")) { return path.replace(/^\/Users\/[^/]+/, "~") @@ -167,8 +198,14 @@ const FolderSelectionView: Component = (props) => { } return ( -
-
+
+
@@ -205,14 +242,17 @@ const FolderSelectionView: Component = (props) => { class="panel-list-item" classList={{ "panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(), + "panel-list-item-disabled": isLoading(), }} >
+ +
+
+
+

Starting instance…

+

Hang tight while we prepare your workspace.

+
+
+
) } diff --git a/src/components/info-view.tsx b/src/components/info-view.tsx index 41555f8d..2310554b 100644 --- a/src/components/info-view.tsx +++ b/src/components/info-view.tsx @@ -1,5 +1,5 @@ import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js" -import { instances, getInstanceLogs } from "../stores/instances" +import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances" import { ChevronDown } from "lucide-solid" import InstanceInfo from "./instance-info" @@ -16,8 +16,13 @@ const InfoView: Component = (props) => { const instance = () => instances().get(props.instanceId) const logs = createMemo(() => getInstanceLogs(props.instanceId)) + const streamingEnabled = createMemo(() => isInstanceLogStreaming(props.instanceId)) + const handleEnableLogs = () => setInstanceLogStreaming(props.instanceId, true) + const handleDisableLogs = () => setInstanceLogStreaming(props.instanceId, false) + onMount(() => { + if (scrollRef && savedState) { scrollRef.scrollTop = savedState.scrollTop } @@ -86,33 +91,58 @@ const InfoView: Component = (props) => {

Server Logs

+
+ + Show server logs + + } + > + + +
- +
0} + when={streamingEnabled()} fallback={ -
Waiting for server output...
+
+

Server logs are paused

+

Enable streaming to watch your OpenCode server activity.

+ +
} > - - {(entry) => ( -
- - {formatTime(entry.timestamp)} - - {entry.message} -
- )} -
+ 0} + fallback={
Waiting for server output...
} + > + + {(entry) => ( +
+ + {formatTime(entry.timestamp)} + + {entry.message} +
+ )} +
+
- - + + + } + > + + +
0}> @@ -108,21 +127,34 @@ const LogsView: Component = (props) => { class="log-content" > 0} - fallback={
Waiting for server output...
} + when={streamingEnabled()} + fallback={ +
+

Server logs are paused

+

Enable streaming to watch your OpenCode server activity.

+ +
+ } > - - {(entry) => ( -
- {formatTime(entry.timestamp)} - {entry.message} -
- )} -
+ 0} + fallback={
Waiting for server output...
} + > + + {(entry) => ( +
+ {formatTime(entry.timestamp)} + {entry.message} +
+ )} +
+
- - + +
+ ) } diff --git a/src/stores/instances.ts b/src/stores/instances.ts index 5e23028b..0e838c75 100644 --- a/src/stores/instances.ts +++ b/src/stores/instances.ts @@ -14,6 +14,7 @@ import { preferences, updateLastUsedBinary } from "./preferences" const [instances, setInstances] = createSignal>(new Map()) const [activeInstanceId, setActiveInstanceId] = createSignal(null) const [instanceLogs, setInstanceLogs] = createSignal>(new Map()) +const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) const MAX_LOG_ENTRIES = 1000 @@ -28,6 +29,17 @@ function ensureLogContainer(id: string) { }) } +function ensureLogStreamingState(id: string) { + setLogStreamingState((prev) => { + if (prev.has(id)) { + return prev + } + const next = new Map(prev) + next.set(id, false) + return next + }) +} + function removeLogContainer(id: string) { setInstanceLogs((prev) => { if (!prev.has(id)) { @@ -37,12 +49,36 @@ function removeLogContainer(id: string) { next.delete(id) return next }) + setLogStreamingState((prev) => { + if (!prev.has(id)) { + return prev + } + const next = new Map(prev) + next.delete(id) + return next + }) } function getInstanceLogs(instanceId: string): LogEntry[] { return instanceLogs().get(instanceId) ?? [] } +function isInstanceLogStreaming(instanceId: string): boolean { + return logStreamingState().get(instanceId) ?? false +} + +function setInstanceLogStreaming(instanceId: string, enabled: boolean) { + ensureLogStreamingState(instanceId) + setLogStreamingState((prev) => { + const next = new Map(prev) + next.set(instanceId, enabled) + return next + }) + if (!enabled) { + clearLogs(instanceId) + } +} + function addInstance(instance: Instance) { setInstances((prev) => { const next = new Map(prev) @@ -50,6 +86,7 @@ function addInstance(instance: Instance) { return next }) ensureLogContainer(instance.id) + ensureLogStreamingState(instance.id) } function updateInstance(id: string, updates: Partial) { @@ -181,6 +218,10 @@ function getActiveInstance(): Instance | null { } function addLog(id: string, entry: LogEntry) { + if (!isInstanceLogStreaming(id)) { + return + } + setInstanceLogs((prev) => { const next = new Map(prev) const existing = next.get(id) ?? [] @@ -215,4 +256,6 @@ export { clearLogs, instanceLogs, getInstanceLogs, + isInstanceLogStreaming, + setInstanceLogStreaming, } diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index 113d14bf..0f351905 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -991,6 +991,10 @@ function isSessionBusy(instanceId: string, sessionId: string): boolean { return true } +function isSessionMessagesLoading(instanceId: string, sessionId: string): boolean { + return Boolean(loading().loadingMessages.get(instanceId)?.has(sessionId)) +} + async function loadMessages(instanceId: string, sessionId: string, force = false): Promise { // If force reload, clear the loaded cache if (force) { @@ -1834,24 +1838,6 @@ function handleTuiToast(_instanceId: string, event: any): void { }) } -function handleSessionIdle(instanceId: string, event: any): void { - const sessionID = event.properties?.sessionID - if (!sessionID) return - - const instanceSessions = sessions().get(instanceId) - const session = instanceSessions?.get(sessionID) - const label = session?.title?.trim() ? session.title : sessionID - const instanceFolder = instances().get(instanceId)?.folder ?? instanceId - const instanceName = instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder - - showToastNotification({ - title: instanceName, - message: `Session ${label ? `"${label}"` : sessionID} is idle`, - variant: "info", - duration: 10000, - }) -} - sseManager.onMessageUpdate = handleMessageUpdate sseManager.onMessageRemoved = handleMessageRemoved sseManager.onMessagePartRemoved = handleMessagePartRemoved @@ -1859,7 +1845,6 @@ sseManager.onSessionUpdate = handleSessionUpdate sseManager.onSessionCompacted = handleSessionCompacted sseManager.onSessionError = handleSessionError sseManager.onTuiToast = handleTuiToast -sseManager.onSessionIdle = handleSessionIdle export { sessions, @@ -1889,6 +1874,7 @@ export { getChildSessions, getSessionFamily, isSessionBusy, + isSessionMessagesLoading, updateSessionAgent, updateSessionModel, getDefaultModel, diff --git a/src/styles/components.css b/src/styles/components.css index 6216891c..abd72bf3 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -112,6 +112,27 @@ button.button-primary { @apply cursor-not-allowed opacity-50; } +.button-tertiary { + @apply inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors; + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-base); +} + +.button-tertiary:hover:not(:disabled) { + color: var(--text-primary); + background-color: var(--surface-hover); +} + +.button-tertiary:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary); +} + +.button-tertiary:disabled { + @apply cursor-not-allowed opacity-50; +} + /* Message item base styles */ .message-item-base { @@ -1131,6 +1152,38 @@ button.button-primary { animation: spin 1s linear infinite; } +.folder-loading-overlay { + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + backdrop-filter: blur(2px); +} + +.folder-loading-indicator { + @apply flex flex-col items-center gap-3 text-center; + padding: 24px 32px; + border-radius: 16px; + background-color: var(--surface-base); + border: 1px solid var(--border-base); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + min-width: 260px; +} + +.folder-loading-text { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.folder-loading-subtext { + font-size: var(--font-size-sm); + color: var(--text-secondary); +} + @keyframes spin { to { transform: rotate(360deg); } } @@ -1695,6 +1748,18 @@ button.button-primary { background-color: transparent; } +.panel-list-item-content:disabled { + opacity: 0.6; +} + +.panel-list-item button:disabled { + cursor: not-allowed; +} + +.panel-list-item-disabled { + opacity: 0.6; +} + .panel-empty-state { @apply p-6 text-center; } @@ -1781,6 +1846,25 @@ button.button-primary { color: var(--text-muted); } +.log-paused-state { + @apply flex flex-col items-center justify-center gap-3 text-center py-10 px-6; + border: 1px dashed var(--border-base); + border-radius: 12px; + background-color: var(--surface-base); +} + +.log-paused-title { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.log-paused-description { + font-size: var(--font-size-sm); + color: var(--text-secondary); + max-width: 320px; +} + /* Environment variables display */ .env-vars-container { @apply px-4 py-3 border-b;