From 85df6781c3f41bc79cfb1e764f08aa7a50d1a721 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 12 Nov 2025 23:11:23 +0000 Subject: [PATCH] Session status implementation. --- src/App.tsx | 5 +- src/components/session-list.tsx | 203 +++++++++++++++---------------- src/stores/session-compaction.ts | 24 ++++ src/stores/session-status.ts | 166 +++++++++++++++++++++++++ src/stores/sessions.ts | 64 +++++++--- src/styles/components.css | 128 +++++++++++++++++-- src/types/session.ts | 2 + 7 files changed, 460 insertions(+), 132 deletions(-) create mode 100644 src/stores/session-compaction.ts create mode 100644 src/stores/session-status.ts diff --git a/src/App.tsx b/src/App.tsx index b2da8ca9..7a45b457 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,10 +58,10 @@ import { updateSessionAgent, updateSessionModel, agents, - isSessionBusy, getSessionInfo, isSessionMessagesLoading, } from "./stores/sessions" +import { isSessionBusy } from "./stores/session-status" import { setupTabKeyboardShortcuts } from "./lib/keyboard" import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette } from "./stores/command-palette" import { registerNavigationShortcuts } from "./lib/shortcuts/navigation" @@ -70,6 +70,7 @@ import { registerAgentShortcuts } from "./lib/shortcuts/agent" import { registerEscapeShortcut, setEscapeStateChangeHandler } from "./lib/shortcuts/escape" import { keyboardRegistry } from "./lib/keyboard-registry" import type { KeyboardShortcut } from "./lib/keyboard-registry" +import { setSessionCompactionState } from "./stores/session-compaction" const SessionView: Component<{ sessionId: string @@ -620,6 +621,7 @@ const App: Component = () => { if (!session) return try { + setSessionCompactionState(instance.id, sessionId, true) console.log("Compacting session...") await instance.client.session.summarize({ path: { id: sessionId }, @@ -629,6 +631,7 @@ const App: Component = () => { }, }) } catch (error: unknown) { + setSessionCompactionState(instance.id, sessionId, false) console.error("Failed to compact session:", error) const message = error instanceof Error ? error.message : "Failed to compact session" alert(`Compact failed: ${message}`) diff --git a/src/components/session-list.tsx b/src/components/session-list.tsx index ed2594b9..3e759bc3 100644 --- a/src/components/session-list.tsx +++ b/src/components/session-list.tsx @@ -1,5 +1,6 @@ import { Component, For, Show, createSignal, createEffect, onCleanup, onMount, createMemo, JSX } from "solid-js" -import type { Session } from "../types/session" +import type { Session, SessionStatus } from "../types/session" +import { getSessionStatus } from "../stores/session-status" import { MessageSquare, Info, X, Copy } from "lucide-solid" import KeyboardHint from "./keyboard-hint" import Kbd from "./kbd" @@ -27,6 +28,17 @@ const MAX_WIDTH = 500 const DEFAULT_WIDTH = 280 const STORAGE_KEY = "opencode-session-sidebar-width" +function formatSessionStatus(status: SessionStatus): string { + switch (status) { + case "working": + return "Working" + case "compacting": + return "Compacting" + default: + return "Idle" + } +} + function arraysEqual(prev: readonly string[] | undefined, next: readonly string[]): boolean { if (!prev) { return false @@ -100,7 +112,7 @@ const SessionList: Component = (props) => { } const clampWidth = (width: number) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width)) - + const removeMouseListeners = () => { if (mouseMoveHandler) { @@ -112,7 +124,7 @@ const SessionList: Component = (props) => { mouseUpHandler = null } } - + const removeTouchListeners = () => { if (touchMoveHandler) { document.removeEventListener("touchmove", touchMoveHandler) @@ -123,68 +135,132 @@ const SessionList: Component = (props) => { touchEndHandler = null } } - + const stopResizing = () => { setIsResizing(false) removeMouseListeners() removeTouchListeners() } - + const handleMouseMove = (event: MouseEvent) => { if (!isResizing()) return const diff = event.clientX - startX() const newWidth = clampWidth(startWidth() + diff) setSidebarWidth(newWidth) } - + const handleMouseUp = () => { stopResizing() } - + const handleTouchMove = (event: TouchEvent) => { if (!isResizing()) return const touch = event.touches[0] + if (!touch) return const diff = touch.clientX - startX() const newWidth = clampWidth(startWidth() + diff) setSidebarWidth(newWidth) } - + const handleTouchEnd = () => { stopResizing() } - + const handleMouseDown = (event: MouseEvent) => { event.preventDefault() setIsResizing(true) setStartX(event.clientX) setStartWidth(sidebarWidth()) - + mouseMoveHandler = handleMouseMove mouseUpHandler = handleMouseUp - + document.addEventListener("mousemove", handleMouseMove) document.addEventListener("mouseup", handleMouseUp) } - + const handleTouchStart = (event: TouchEvent) => { event.preventDefault() const touch = event.touches[0] + if (!touch) return setIsResizing(true) setStartX(touch.clientX) setStartWidth(sidebarWidth()) - + touchMoveHandler = handleTouchMove touchEndHandler = handleTouchEnd - + document.addEventListener("touchmove", handleTouchMove) document.addEventListener("touchend", handleTouchEnd) } - + onCleanup(() => { removeMouseListeners() removeTouchListeners() }) - + + const SessionRow: Component<{ sessionId: string; canClose?: boolean }> = (rowProps) => { + const session = () => props.sessions.get(rowProps.sessionId) + if (!session()) { + return <> + } + const isActive = () => props.activeSessionId === rowProps.sessionId + const title = () => session()?.title || "Untitled" + const status = () => getSessionStatus(props.instanceId, rowProps.sessionId) + const statusLabel = () => formatSessionStatus(status()) + + return ( +
+ +
+ ) + } + const userSessionIds = createMemo( () => { const ids: string[] = [] @@ -198,7 +274,7 @@ const SessionList: Component = (props) => { undefined, { equals: arraysEqual }, ) - + const childSessionIds = createMemo( () => { const children: { id: string; updated: number }[] = [] @@ -216,9 +292,10 @@ const SessionList: Component = (props) => { undefined, { equals: arraysEqual }, ) - + return (
@@ -271,56 +348,7 @@ const SessionList: Component = (props) => {
User Sessions
- - {(id) => { - const session = () => props.sessions.get(id) - if (!session()) { - return null - } - - const isActive = () => props.activeSessionId === id - const title = () => session()?.title || "Untitled" - - return ( -
- -
- ) - }} -
+ {(id) => }
@@ -329,44 +357,7 @@ const SessionList: Component = (props) => {
Agent Sessions
- - {(id) => { - const session = () => props.sessions.get(id) - if (!session()) { - return null - } - - const isActive = () => props.activeSessionId === id - const title = () => session()?.title || "Untitled" - - return ( -
- -
- ) - }} -
+ {(id) => } diff --git a/src/stores/session-compaction.ts b/src/stores/session-compaction.ts new file mode 100644 index 00000000..fdf62315 --- /dev/null +++ b/src/stores/session-compaction.ts @@ -0,0 +1,24 @@ +import { createSignal } from "solid-js" + +function makeKey(instanceId: string, sessionId: string): string { + return `${instanceId}:${sessionId}` +} + +const [compactingSessions, setCompactingSessions] = createSignal>(new Map()) + +export function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void { + setCompactingSessions((prev) => { + const next = new Map(prev) + const key = makeKey(instanceId, sessionId) + if (isCompacting) { + next.set(key, true) + } else { + next.delete(key) + } + return next + }) +} + +export function isSessionCompactionActive(instanceId: string, sessionId: string): boolean { + return compactingSessions().get(makeKey(instanceId, sessionId)) ?? false +} diff --git a/src/stores/session-status.ts b/src/stores/session-status.ts new file mode 100644 index 00000000..b38cb936 --- /dev/null +++ b/src/stores/session-status.ts @@ -0,0 +1,166 @@ +import type { Session, SessionStatus } from "../types/session" +import type { Message, MessageInfo } from "../types/message" +import { sessions } from "./sessions" +import { isSessionCompactionActive } from "./session-compaction" + +function getSession(instanceId: string, sessionId: string): Session | null { + const instanceSessions = sessions().get(instanceId) + return instanceSessions?.get(sessionId) ?? null +} + +function isSessionCompacting(session: Session): boolean { + const time = (session.time as (Session["time"] & { compacting?: number }) | undefined) + const compactingFlag = time?.compacting + if (typeof compactingFlag === "number") { + return compactingFlag > 0 + } + return Boolean(compactingFlag) +} + +function getMessageTimestamp(session: Session, message?: Message): number { + if (!message) return Number.NEGATIVE_INFINITY + if (typeof message.timestamp === "number" && Number.isFinite(message.timestamp)) { + return message.timestamp + } + const info = session.messagesInfo.get(message.id) + return info?.time?.created ?? Number.NEGATIVE_INFINITY +} + +function getLastMessage(session: Session): Message | undefined { + let latest: Message | undefined + let latestTimestamp = Number.NEGATIVE_INFINITY + for (const message of session.messages) { + if (!message) continue + const timestamp = getMessageTimestamp(session, message) + if (timestamp >= latestTimestamp) { + latest = message + latestTimestamp = timestamp + } + } + return latest +} + +function getLastMessageInfo(session: Session, role?: MessageInfo["role"]): MessageInfo | undefined { + if (session.messagesInfo.size === 0) { + return undefined + } + let latest: MessageInfo | undefined + let latestTimestamp = Number.NEGATIVE_INFINITY + for (const info of session.messagesInfo.values()) { + if (!info) continue + if (role && info.role !== role) continue + const timestamp = info.time?.created ?? 0 + if (timestamp >= latestTimestamp) { + latest = info + latestTimestamp = timestamp + } + } + return latest +} + +function getInfoCreatedTimestamp(info?: MessageInfo): number { + if (!info) { + return Number.NEGATIVE_INFINITY + } + const created = info.time?.created + if (typeof created === "number" && Number.isFinite(created)) { + return created + } + return Number.NEGATIVE_INFINITY +} + +function getAssistantCompletionTimestamp(info?: MessageInfo): number { + if (!info) { + return Number.NEGATIVE_INFINITY + } + const completed = (info.time as { completed?: number } | undefined)?.completed + if (typeof completed === "number" && Number.isFinite(completed)) { + return completed + } + return Number.NEGATIVE_INFINITY +} + +function isAssistantInfoPending(info?: MessageInfo): boolean { + if (!info) { + return false + } + const completed = (info.time as { completed?: number } | undefined)?.completed + if (completed === undefined || completed === null) { + return true + } + const created = getInfoCreatedTimestamp(info) + return completed < created +} + +function isAssistantStillGenerating(message: Message, info?: MessageInfo): boolean { + if (message.type !== "assistant") { + return false + } + + if (message.status === "error") { + return false + } + + if (message.status === "streaming" || message.status === "sending") { + return true + } + + const completedAt = (info?.time as { completed?: number } | undefined)?.completed + if (completedAt !== undefined && completedAt !== null) { + return false + } + + return !(message.status === "complete" || message.status === "sent") +} + +export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus { + const session = getSession(instanceId, sessionId) + if (!session) { + return "idle" + } + + if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) { + return "compacting" + } + + const latestUserInfo = getLastMessageInfo(session, "user") + const latestAssistantInfo = getLastMessageInfo(session, "assistant") + const lastMessage = getLastMessage(session) + if (!lastMessage) { + const latestInfo = getLastMessageInfo(session) + if (!latestInfo) { + return "idle" + } + if (latestInfo.role === "user") { + return "working" + } + const infoCompleted = latestInfo.time?.completed + return infoCompleted ? "idle" : "working" + } + + if (lastMessage.type === "user") { + return "working" + } + + const infoForMessage = session.messagesInfo.get(lastMessage.id) ?? latestAssistantInfo + if (isAssistantStillGenerating(lastMessage, infoForMessage)) { + return "working" + } + + if (isAssistantInfoPending(latestAssistantInfo)) { + return "working" + } + + const userTimestamp = getInfoCreatedTimestamp(latestUserInfo) + const assistantCompletedAt = getAssistantCompletionTimestamp(latestAssistantInfo) + if (userTimestamp > assistantCompletedAt) { + return "working" + } + + return "idle" +} + +export function isSessionBusy(instanceId: string, sessionId: string): boolean { + const status = getSessionStatus(instanceId, sessionId) + return status === "working" || status === "compacting" +} diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index c1218f1d..b7417b5c 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -8,6 +8,7 @@ import { sseManager } from "../lib/sse-manager" import { decodeHtmlEntities } from "../lib/markdown" import { showToastNotification, ToastVariant } from "../lib/notifications" import { preferences, addRecentModelPreference, getAgentModelPreference, setAgentModelPreference } from "./preferences" +import { setSessionCompactionState } from "./session-compaction" import type { EventSessionUpdated, EventSessionCompacted, @@ -57,6 +58,7 @@ interface SessionForkResponse { } const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000 + const ALLOWED_TOAST_VARIANTS = new Set(["info", "success", "warning", "error"]) const [sessions, setSessions] = createSignal>>(new Map()) @@ -332,6 +334,14 @@ function withSession(instanceId: string, sessionId: string, updater: (session: S }) } +function setSessionCompactingState(instanceId: string, sessionId: string, isCompacting: boolean): void { + withSession(instanceId, sessionId, (session) => { + const time = { ...(session.time ?? {}) } + time.compacting = isCompacting ? Date.now() : 0 + session.time = time + }) +} + const ID_LENGTH = 26 const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" @@ -408,8 +418,7 @@ async function fetchSessions(instanceId: string): Promise { model: { providerId: "", modelId: "" }, version: apiSession.version, // Include version from SDK time: { - created: apiSession.time.created, - updated: apiSession.time.updated, + ...apiSession.time, }, revert: apiSession.revert ? { @@ -430,6 +439,12 @@ async function fetchSessions(instanceId: string): Promise { return next }) + for (const session of sessionMap.values()) { + const flag = (session.time as (Session["time"] & { compacting?: number | boolean }) | undefined)?.compacting + const active = typeof flag === "number" ? flag > 0 : Boolean(flag) + setSessionCompactionState(instanceId, session.id, active) + } + pruneDraftPrompts(instanceId, new Set(sessionMap.keys())) } catch (error) { console.error("Failed to fetch sessions:", error) @@ -666,8 +681,7 @@ async function createSession(instanceId: string, agent?: string): Promise 0 : Boolean(compactingFlag) + setSessionCompactionState(instanceId, info.id, isCompacting) + const instanceSessions = sessions().get(instanceId) if (!instanceSessions) return @@ -1739,10 +1755,12 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo modelId: "", }, version: info.version || "0", - time: { - created: info.time?.created || Date.now(), - updated: info.time?.updated || Date.now(), - }, + time: info.time + ? { ...info.time } + : { + created: Date.now(), + updated: Date.now(), + }, messages: [], messagesInfo: new Map(), } @@ -1757,13 +1775,18 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo console.log(`[SSE] New session created: ${info.id}`, newSession) } else { + const mergedTime = { + ...existingSession.time, + ...(info.time ?? {}), + } + if (!info.time?.updated) { + mergedTime.updated = Date.now() + } + const updatedSession = { ...existingSession, title: info.title || existingSession.title, - time: { - ...existingSession.time, - updated: info.time?.updated || Date.now(), - }, + time: mergedTime, revert: info.revert ? { messageID: info.revert.messageID, @@ -2067,6 +2090,15 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted if (!sessionID) return console.log(`[SSE] Session compacted: ${sessionID}`) + + setSessionCompactionState(instanceId, sessionID, false) + + withSession(instanceId, sessionID, (session) => { + const time = { ...(session.time ?? {}) } + time.compacting = 0 + session.time = time + }) + loadMessages(instanceId, sessionID, true).catch(console.error) const instanceSessions = sessions().get(instanceId) diff --git a/src/styles/components.css b/src/styles/components.css index f411507a..2a19f7d9 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -530,6 +530,28 @@ button.button-primary { color: var(--text-muted); } +:root { + --session-status-working-fg: #b45309; + --session-status-working-bg: rgba(245, 158, 11, 0.16); + --session-status-compacting-fg: #6d28d9; + --session-status-compacting-bg: rgba(109, 40, 217, 0.18); + --session-status-idle-fg: #15803d; + --session-status-idle-bg: rgba(22, 163, 74, 0.16); + --list-item-highlight-bg: rgba(0, 102, 255, 0.1); + --list-item-highlight-border: rgba(0, 102, 255, 0.25); +} + +[data-theme="dark"] { + --session-status-working-fg: #facc15; + --session-status-working-bg: rgba(250, 204, 21, 0.25); + --session-status-compacting-fg: #c084fc; + --session-status-compacting-bg: rgba(192, 132, 252, 0.28); + --session-status-idle-fg: #4ade80; + --session-status-idle-bg: rgba(74, 222, 128, 0.22); + --list-item-highlight-bg: rgba(0, 128, 255, 0.2); + --list-item-highlight-border: rgba(0, 128, 255, 0.4); +} + .status-indicator { @apply flex items-center gap-1.5 text-xs; color: var(--text-muted); @@ -552,6 +574,67 @@ button.button-primary { background-color: var(--status-error); } +.status-indicator.session-status { + --session-status-dot: var(--text-muted); +} + +.status-indicator.session-status.session-working, +.status-indicator.session-status.session-compacting, +.status-indicator.session-status.session-idle { + font-weight: var(--font-weight-medium); +} + +.status-indicator.session-status.session-working { + color: var(--session-status-working-fg); + --session-status-dot: var(--session-status-working-fg); +} + +.status-indicator.session-status.session-compacting { + color: var(--session-status-compacting-fg); + --session-status-dot: var(--session-status-compacting-fg); +} + +.status-indicator.session-status.session-idle { + color: var(--session-status-idle-fg); + --session-status-dot: var(--session-status-idle-fg); +} + +.status-indicator.session-status .status-dot { + background-color: var(--session-status-dot); +} + +.status-indicator.session-status.session-working .status-dot, +.status-indicator.session-status.session-compacting .status-dot { + animation: pulse 1.5s ease-in-out infinite; +} + +.status-indicator.session-status.session-working.session-status-list { + background-color: var(--session-status-working-bg); +} + +.status-indicator.session-status.session-compacting.session-status-list { + background-color: var(--session-status-compacting-bg); +} + +.status-indicator.session-status.session-idle.session-status-list { + background-color: var(--session-status-idle-bg); +} + +.status-indicator.session-status-list { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: var(--font-weight-medium); + color: inherit; + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + border: 1px solid transparent; +} + + .message-stream { @apply flex-1 min-h-0 overflow-y-auto p-4 flex flex-col gap-4; background-color: var(--surface-base); @@ -1810,11 +1893,8 @@ button.button-primary { } .panel-list-item-highlight { - background-color: rgba(0, 102, 255, 0.1) !important; -} - -[data-theme="dark"] .panel-list-item-highlight { - background-color: rgba(0, 128, 255, 0.2) !important; + background-color: var(--list-item-highlight-bg) !important; + box-shadow: inset 0 0 0 1px var(--list-item-highlight-border); } .panel-list-item-content { @@ -2099,7 +2179,7 @@ button.button-primary { } .session-item-base { - @apply w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors outline-none; + @apply w-full flex flex-col gap-1 px-3 py-2.5 text-left transition-colors outline-none; font-family: var(--font-family-sans); font-size: var(--font-size-sm); } @@ -2110,10 +2190,39 @@ button.button-primary { ring-offset-color: var(--surface-secondary); } +.session-item-row { + @apply flex items-center gap-2 w-full; +} + +.session-item-header { + @apply justify-between; +} + +.session-item-title-row { + @apply flex items-center gap-2 min-w-0 flex-1; +} + +.session-item-meta { + @apply justify-between items-center; + font-size: var(--font-size-xs); + color: var(--text-secondary); + margin-top: 0.125rem; +} + +.session-item-active .session-item-meta { + color: var(--text-secondary); + opacity: 1; +} + +.session-item-actions { + @apply flex items-center gap-1; +} + .session-item-active { - background-color: var(--accent-primary); - color: var(--text-inverted); + background-color: var(--list-item-highlight-bg); + color: var(--text-primary); font-weight: var(--font-weight-medium); + box-shadow: inset 0 0 0 1px var(--list-item-highlight-border); } .session-item-inactive { @@ -2126,7 +2235,8 @@ button.button-primary { } .session-item-active .session-item-close:hover { - background-color: rgba(255, 255, 255, 0.2); + background-color: var(--surface-hover); + color: var(--text-primary); } .session-item-title { diff --git a/src/types/session.ts b/src/types/session.ts index 2ac990ef..5ba83340 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -14,6 +14,8 @@ export type { Model as SDKModel } from "@opencode-ai/sdk" +export type SessionStatus = "idle" | "working" | "compacting" + // Our client-specific Session interface extending SDK Session export interface Session extends Omit { instanceId: string // Client-specific field