improve instance launch and log streaming UX

This commit is contained in:
Shantur Rathore
2025-11-09 02:06:05 +00:00
parent 9bba0aad8a
commit 47f3948aec
7 changed files with 296 additions and 65 deletions

View File

@@ -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}
/>

View File

@@ -18,15 +18,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (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<FolderSelectionViewProps> = (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<FolderSelectionViewProps> = (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<FolderSelectionViewProps> = (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<FolderSelectionViewProps> = (props) => {
}
}
function getDisplayPath(path: string): string {
if (path.startsWith("/Users/")) {
return path.replace(/^\/Users\/[^/]+/, "~")
@@ -167,8 +198,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
}
return (
<div class="flex h-screen w-full items-start justify-center overflow-hidden py-6" style="background-color: var(--surface-secondary)">
<div class="w-full max-w-3xl h-full max-h-[90vh] px-8 flex flex-col overflow-hidden">
<div
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 relative"
style="background-color: var(--surface-secondary)"
>
<div
class="w-full max-w-3xl h-full max-h-[90vh] px-8 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<Folder class="h-16 w-16 icon-muted" />
@@ -205,14 +242,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
@@ -239,6 +279,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title="Remove from recent"
>
@@ -334,6 +375,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
</div>
</div>
<Show when={isLoading()}>
<div class="folder-loading-overlay">
<div class="folder-loading-indicator">
<div class="spinner" />
<p class="folder-loading-text">Starting instance</p>
<p class="folder-loading-subtext">Hang tight while we prepare your workspace.</p>
</div>
</div>
</Show>
</div>
)
}

View File

@@ -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<InfoViewProps> = (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<InfoViewProps> = (props) => {
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
<div class="log-header">
<h2 class="panel-title">Server Logs</h2>
<div class="flex items-center gap-2">
<Show
when={streamingEnabled()}
fallback={
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
Show server logs
</button>
}
>
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
Hide server logs
</button>
</Show>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
class="log-content"
>
<Show
when={logs().length > 0}
when={streamingEnabled()}
fallback={
<div class="log-empty-state">Waiting for server output...</div>
<div class="log-paused-state">
<p class="log-paused-title">Server logs are paused</p>
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
<button type="button" class="button-primary" onClick={handleEnableLogs}>
Show server logs
</button>
</div>
}
>
<For each={logs()}>
{(entry) => (
<div class="log-entry">
<span class="log-timestamp">
{formatTime(entry.timestamp)}
</span>
<span class={`log-message ${getLevelColor(entry.level)}`}>{entry.message}</span>
</div>
)}
</For>
<Show
when={logs().length > 0}
fallback={<div class="log-empty-state">Waiting for server output...</div>}
>
<For each={logs()}>
{(entry) => (
<div class="log-entry">
<span class="log-timestamp">
{formatTime(entry.timestamp)}
</span>
<span class={`log-message ${getLevelColor(entry.level)}`}>{entry.message}</span>
</div>
)}
</For>
</Show>
</Show>
</div>
<Show when={!autoScroll()}>
<Show when={!autoScroll() && streamingEnabled()}>
<button
onClick={scrollToBottom}
class="scroll-to-bottom"
@@ -127,4 +157,5 @@ const InfoView: Component<InfoViewProps> = (props) => {
)
}
export default InfoView

View File

@@ -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"
interface LogsViewProps {
@@ -15,8 +15,13 @@ const LogsView: Component<LogsViewProps> = (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
}
@@ -79,6 +84,20 @@ const LogsView: Component<LogsViewProps> = (props) => {
<div class="log-container">
<div class="log-header">
<h3 class="text-sm font-medium" style="color: var(--text-secondary)">Server Logs</h3>
<div class="flex items-center gap-2">
<Show
when={streamingEnabled()}
fallback={
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
Show server logs
</button>
}
>
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
Hide server logs
</button>
</Show>
</div>
</div>
<Show when={instance()?.environmentVariables && Object.keys(instance()?.environmentVariables!).length > 0}>
@@ -108,21 +127,34 @@ const LogsView: Component<LogsViewProps> = (props) => {
class="log-content"
>
<Show
when={logs().length > 0}
fallback={<div class="log-empty-state">Waiting for server output...</div>}
when={streamingEnabled()}
fallback={
<div class="log-paused-state">
<p class="log-paused-title">Server logs are paused</p>
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
<button type="button" class="button-primary" onClick={handleEnableLogs}>
Show server logs
</button>
</div>
}
>
<For each={logs()}>
{(entry) => (
<div class="log-entry">
<span class="log-timestamp">{formatTime(entry.timestamp)}</span>
<span class={`log-message ${getLevelColor(entry.level)}`}>{entry.message}</span>
</div>
)}
</For>
<Show
when={logs().length > 0}
fallback={<div class="log-empty-state">Waiting for server output...</div>}
>
<For each={logs()}>
{(entry) => (
<div class="log-entry">
<span class="log-timestamp">{formatTime(entry.timestamp)}</span>
<span class={`log-message ${getLevelColor(entry.level)}`}>{entry.message}</span>
</div>
)}
</For>
</Show>
</Show>
</div>
<Show when={!autoScroll()}>
<Show when={!autoScroll() && streamingEnabled()}>
<button
onClick={scrollToBottom}
class="scroll-to-bottom"
@@ -132,6 +164,7 @@ const LogsView: Component<LogsViewProps> = (props) => {
</button>
</Show>
</div>
)
}

View File

@@ -14,6 +14,7 @@ import { preferences, updateLastUsedBinary } from "./preferences"
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(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<Instance>) {
@@ -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,
}

View File

@@ -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<void> {
// 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,

View File

@@ -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;