Session status implementation.
This commit is contained in:
@@ -58,10 +58,10 @@ import {
|
|||||||
updateSessionAgent,
|
updateSessionAgent,
|
||||||
updateSessionModel,
|
updateSessionModel,
|
||||||
agents,
|
agents,
|
||||||
isSessionBusy,
|
|
||||||
getSessionInfo,
|
getSessionInfo,
|
||||||
isSessionMessagesLoading,
|
isSessionMessagesLoading,
|
||||||
} from "./stores/sessions"
|
} from "./stores/sessions"
|
||||||
|
import { isSessionBusy } from "./stores/session-status"
|
||||||
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
|
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
|
||||||
import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette } from "./stores/command-palette"
|
import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette } from "./stores/command-palette"
|
||||||
import { registerNavigationShortcuts } from "./lib/shortcuts/navigation"
|
import { registerNavigationShortcuts } from "./lib/shortcuts/navigation"
|
||||||
@@ -70,6 +70,7 @@ import { registerAgentShortcuts } from "./lib/shortcuts/agent"
|
|||||||
import { registerEscapeShortcut, setEscapeStateChangeHandler } from "./lib/shortcuts/escape"
|
import { registerEscapeShortcut, setEscapeStateChangeHandler } from "./lib/shortcuts/escape"
|
||||||
import { keyboardRegistry } from "./lib/keyboard-registry"
|
import { keyboardRegistry } from "./lib/keyboard-registry"
|
||||||
import type { KeyboardShortcut } from "./lib/keyboard-registry"
|
import type { KeyboardShortcut } from "./lib/keyboard-registry"
|
||||||
|
import { setSessionCompactionState } from "./stores/session-compaction"
|
||||||
|
|
||||||
const SessionView: Component<{
|
const SessionView: Component<{
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -620,6 +621,7 @@ const App: Component = () => {
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setSessionCompactionState(instance.id, sessionId, true)
|
||||||
console.log("Compacting session...")
|
console.log("Compacting session...")
|
||||||
await instance.client.session.summarize({
|
await instance.client.session.summarize({
|
||||||
path: { id: sessionId },
|
path: { id: sessionId },
|
||||||
@@ -629,6 +631,7 @@ const App: Component = () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
setSessionCompactionState(instance.id, sessionId, false)
|
||||||
console.error("Failed to compact session:", error)
|
console.error("Failed to compact session:", error)
|
||||||
const message = error instanceof Error ? error.message : "Failed to compact session"
|
const message = error instanceof Error ? error.message : "Failed to compact session"
|
||||||
alert(`Compact failed: ${message}`)
|
alert(`Compact failed: ${message}`)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, For, Show, createSignal, createEffect, onCleanup, onMount, createMemo, JSX } from "solid-js"
|
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 { MessageSquare, Info, X, Copy } from "lucide-solid"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
@@ -27,6 +28,17 @@ const MAX_WIDTH = 500
|
|||||||
const DEFAULT_WIDTH = 280
|
const DEFAULT_WIDTH = 280
|
||||||
const STORAGE_KEY = "opencode-session-sidebar-width"
|
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 {
|
function arraysEqual(prev: readonly string[] | undefined, next: readonly string[]): boolean {
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
return false
|
return false
|
||||||
@@ -144,6 +156,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
const handleTouchMove = (event: TouchEvent) => {
|
const handleTouchMove = (event: TouchEvent) => {
|
||||||
if (!isResizing()) return
|
if (!isResizing()) return
|
||||||
const touch = event.touches[0]
|
const touch = event.touches[0]
|
||||||
|
if (!touch) return
|
||||||
const diff = touch.clientX - startX()
|
const diff = touch.clientX - startX()
|
||||||
const newWidth = clampWidth(startWidth() + diff)
|
const newWidth = clampWidth(startWidth() + diff)
|
||||||
setSidebarWidth(newWidth)
|
setSidebarWidth(newWidth)
|
||||||
@@ -169,6 +182,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
const handleTouchStart = (event: TouchEvent) => {
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const touch = event.touches[0]
|
const touch = event.touches[0]
|
||||||
|
if (!touch) return
|
||||||
setIsResizing(true)
|
setIsResizing(true)
|
||||||
setStartX(touch.clientX)
|
setStartX(touch.clientX)
|
||||||
setStartWidth(sidebarWidth())
|
setStartWidth(sidebarWidth())
|
||||||
@@ -185,6 +199,68 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
removeTouchListeners()
|
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 (
|
||||||
|
<div class="session-list-item group">
|
||||||
|
<button
|
||||||
|
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||||
|
onClick={() => selectSession(rowProps.sessionId)}
|
||||||
|
title={title()}
|
||||||
|
role="button"
|
||||||
|
aria-selected={isActive()}
|
||||||
|
>
|
||||||
|
<div class="session-item-row session-item-header">
|
||||||
|
<div class="session-item-title-row">
|
||||||
|
<MessageSquare class="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span class="session-item-title truncate">{title()}</span>
|
||||||
|
</div>
|
||||||
|
<Show when={rowProps.canClose}>
|
||||||
|
<span
|
||||||
|
class="session-item-close opacity-0 group-hover:opacity-100 hover:bg-status-error hover:text-white rounded p-0.5 transition-all"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
props.onClose(rowProps.sessionId)
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Close session"
|
||||||
|
>
|
||||||
|
<X class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="session-item-row session-item-meta">
|
||||||
|
<span class={`status-indicator session-status session-status-list session-${status()}`}>
|
||||||
|
<span class="status-dot" />
|
||||||
|
{statusLabel()}
|
||||||
|
</span>
|
||||||
|
<div class="session-item-actions">
|
||||||
|
<span
|
||||||
|
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||||
|
onClick={(event) => copySessionId(event, rowProps.sessionId)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Copy session ID"
|
||||||
|
title="Copy session ID"
|
||||||
|
>
|
||||||
|
<Copy class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const userSessionIds = createMemo(
|
const userSessionIds = createMemo(
|
||||||
() => {
|
() => {
|
||||||
const ids: string[] = []
|
const ids: string[] = []
|
||||||
@@ -219,6 +295,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
||||||
class="session-list-container bg-surface-secondary border-r border-base flex flex-col"
|
class="session-list-container bg-surface-secondary border-r border-base flex flex-col"
|
||||||
style={{ width: `${sidebarWidth()}px` }}
|
style={{ width: `${sidebarWidth()}px` }}
|
||||||
>
|
>
|
||||||
@@ -271,56 +348,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||||
User Sessions
|
User Sessions
|
||||||
</div>
|
</div>
|
||||||
<For each={userSessionIds()}>
|
<For each={userSessionIds()}>{(id) => <SessionRow sessionId={id} canClose />}</For>
|
||||||
{(id) => {
|
|
||||||
const session = () => props.sessions.get(id)
|
|
||||||
if (!session()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = () => props.activeSessionId === id
|
|
||||||
const title = () => session()?.title || "Untitled"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="session-list-item group">
|
|
||||||
<button
|
|
||||||
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
|
||||||
onClick={() => selectSession(id)}
|
|
||||||
title={title()}
|
|
||||||
role="button"
|
|
||||||
aria-selected={isActive()}
|
|
||||||
>
|
|
||||||
<MessageSquare class="w-4 h-4 flex-shrink-0" />
|
|
||||||
<span class="session-item-title truncate">{title()}</span>
|
|
||||||
<div class="flex items-center gap-1 ml-auto">
|
|
||||||
<span
|
|
||||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
|
||||||
onClick={(event) => copySessionId(event, id)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label="Copy session ID"
|
|
||||||
title="Copy session ID"
|
|
||||||
>
|
|
||||||
<Copy class="w-3 h-3" />
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="session-item-close opacity-0 group-hover:opacity-100 hover:bg-status-error hover:text-white rounded p-0.5 transition-all"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
props.onClose(id)
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label="Close session"
|
|
||||||
>
|
|
||||||
<X class="w-3 h-3" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -329,44 +357,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||||
Agent Sessions
|
Agent Sessions
|
||||||
</div>
|
</div>
|
||||||
<For each={childSessionIds()}>
|
<For each={childSessionIds()}>{(id) => <SessionRow sessionId={id} />}</For>
|
||||||
{(id) => {
|
|
||||||
const session = () => props.sessions.get(id)
|
|
||||||
if (!session()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = () => props.activeSessionId === id
|
|
||||||
const title = () => session()?.title || "Untitled"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="session-list-item group">
|
|
||||||
<button
|
|
||||||
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
|
||||||
onClick={() => selectSession(id)}
|
|
||||||
title={title()}
|
|
||||||
role="button"
|
|
||||||
aria-selected={isActive()}
|
|
||||||
>
|
|
||||||
<MessageSquare class="w-4 h-4 flex-shrink-0" />
|
|
||||||
<span class="session-item-title truncate">{title()}</span>
|
|
||||||
<div class="flex items-center gap-1 ml-auto">
|
|
||||||
<span
|
|
||||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
|
||||||
onClick={(event) => copySessionId(event, id)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label="Copy session ID"
|
|
||||||
title="Copy session ID"
|
|
||||||
>
|
|
||||||
<Copy class="w-3 h-3" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
24
src/stores/session-compaction.ts
Normal file
24
src/stores/session-compaction.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
|
||||||
|
function makeKey(instanceId: string, sessionId: string): string {
|
||||||
|
return `${instanceId}:${sessionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const [compactingSessions, setCompactingSessions] = createSignal<Map<string, boolean>>(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
|
||||||
|
}
|
||||||
166
src/stores/session-status.ts
Normal file
166
src/stores/session-status.ts
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { sseManager } from "../lib/sse-manager"
|
|||||||
import { decodeHtmlEntities } from "../lib/markdown"
|
import { decodeHtmlEntities } from "../lib/markdown"
|
||||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||||
import { preferences, addRecentModelPreference, getAgentModelPreference, setAgentModelPreference } from "./preferences"
|
import { preferences, addRecentModelPreference, getAgentModelPreference, setAgentModelPreference } from "./preferences"
|
||||||
|
import { setSessionCompactionState } from "./session-compaction"
|
||||||
import type {
|
import type {
|
||||||
EventSessionUpdated,
|
EventSessionUpdated,
|
||||||
EventSessionCompacted,
|
EventSessionCompacted,
|
||||||
@@ -57,6 +58,7 @@ interface SessionForkResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
|
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
|
||||||
|
|
||||||
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
|
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
|
||||||
|
|
||||||
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
|
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(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 ID_LENGTH = 26
|
||||||
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
|
||||||
@@ -408,8 +418,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
|||||||
model: { providerId: "", modelId: "" },
|
model: { providerId: "", modelId: "" },
|
||||||
version: apiSession.version, // Include version from SDK
|
version: apiSession.version, // Include version from SDK
|
||||||
time: {
|
time: {
|
||||||
created: apiSession.time.created,
|
...apiSession.time,
|
||||||
updated: apiSession.time.updated,
|
|
||||||
},
|
},
|
||||||
revert: apiSession.revert
|
revert: apiSession.revert
|
||||||
? {
|
? {
|
||||||
@@ -430,6 +439,12 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
|||||||
return next
|
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()))
|
pruneDraftPrompts(instanceId, new Set(sessionMap.keys()))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch sessions:", error)
|
console.error("Failed to fetch sessions:", error)
|
||||||
@@ -666,8 +681,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
|||||||
model: defaultModel,
|
model: defaultModel,
|
||||||
version: response.data.version, // Include version from SDK
|
version: response.data.version, // Include version from SDK
|
||||||
time: {
|
time: {
|
||||||
created: response.data.time.created,
|
...response.data.time,
|
||||||
updated: response.data.time.updated,
|
|
||||||
},
|
},
|
||||||
revert: response.data.revert
|
revert: response.data.revert
|
||||||
? {
|
? {
|
||||||
@@ -771,10 +785,7 @@ async function forkSession(
|
|||||||
modelId: info.model?.modelID || "",
|
modelId: info.model?.modelID || "",
|
||||||
},
|
},
|
||||||
version: "0", // Default version for forked sessions
|
version: "0", // Default version for forked sessions
|
||||||
time: {
|
time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() },
|
||||||
created: info.time?.created || Date.now(),
|
|
||||||
updated: info.time?.updated || Date.now(),
|
|
||||||
},
|
|
||||||
revert: info.revert
|
revert: info.revert
|
||||||
? {
|
? {
|
||||||
messageID: info.revert.messageID,
|
messageID: info.revert.messageID,
|
||||||
@@ -849,6 +860,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setSessionCompactionState(instanceId, sessionId, false)
|
||||||
clearSessionDraftPrompt(instanceId, sessionId)
|
clearSessionDraftPrompt(instanceId, sessionId)
|
||||||
|
|
||||||
// Remove session info entry
|
// Remove session info entry
|
||||||
@@ -1722,6 +1734,10 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
|||||||
const info = event.properties?.info
|
const info = event.properties?.info
|
||||||
if (!info) return
|
if (!info) return
|
||||||
|
|
||||||
|
const compactingFlag = info.time?.compacting
|
||||||
|
const isCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag)
|
||||||
|
setSessionCompactionState(instanceId, info.id, isCompacting)
|
||||||
|
|
||||||
const instanceSessions = sessions().get(instanceId)
|
const instanceSessions = sessions().get(instanceId)
|
||||||
if (!instanceSessions) return
|
if (!instanceSessions) return
|
||||||
|
|
||||||
@@ -1739,10 +1755,12 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
|||||||
modelId: "",
|
modelId: "",
|
||||||
},
|
},
|
||||||
version: info.version || "0",
|
version: info.version || "0",
|
||||||
time: {
|
time: info.time
|
||||||
created: info.time?.created || Date.now(),
|
? { ...info.time }
|
||||||
updated: info.time?.updated || Date.now(),
|
: {
|
||||||
},
|
created: Date.now(),
|
||||||
|
updated: Date.now(),
|
||||||
|
},
|
||||||
messages: [],
|
messages: [],
|
||||||
messagesInfo: new Map(),
|
messagesInfo: new Map(),
|
||||||
}
|
}
|
||||||
@@ -1757,13 +1775,18 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
|||||||
|
|
||||||
console.log(`[SSE] New session created: ${info.id}`, newSession)
|
console.log(`[SSE] New session created: ${info.id}`, newSession)
|
||||||
} else {
|
} else {
|
||||||
|
const mergedTime = {
|
||||||
|
...existingSession.time,
|
||||||
|
...(info.time ?? {}),
|
||||||
|
}
|
||||||
|
if (!info.time?.updated) {
|
||||||
|
mergedTime.updated = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
const updatedSession = {
|
const updatedSession = {
|
||||||
...existingSession,
|
...existingSession,
|
||||||
title: info.title || existingSession.title,
|
title: info.title || existingSession.title,
|
||||||
time: {
|
time: mergedTime,
|
||||||
...existingSession.time,
|
|
||||||
updated: info.time?.updated || Date.now(),
|
|
||||||
},
|
|
||||||
revert: info.revert
|
revert: info.revert
|
||||||
? {
|
? {
|
||||||
messageID: info.revert.messageID,
|
messageID: info.revert.messageID,
|
||||||
@@ -2067,6 +2090,15 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
|
|||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
console.log(`[SSE] Session compacted: ${sessionID}`)
|
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)
|
loadMessages(instanceId, sessionID, true).catch(console.error)
|
||||||
|
|
||||||
const instanceSessions = sessions().get(instanceId)
|
const instanceSessions = sessions().get(instanceId)
|
||||||
|
|||||||
@@ -530,6 +530,28 @@ button.button-primary {
|
|||||||
color: var(--text-muted);
|
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 {
|
.status-indicator {
|
||||||
@apply flex items-center gap-1.5 text-xs;
|
@apply flex items-center gap-1.5 text-xs;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -552,6 +574,67 @@ button.button-primary {
|
|||||||
background-color: var(--status-error);
|
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 {
|
.message-stream {
|
||||||
@apply flex-1 min-h-0 overflow-y-auto p-4 flex flex-col gap-4;
|
@apply flex-1 min-h-0 overflow-y-auto p-4 flex flex-col gap-4;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
@@ -1810,11 +1893,8 @@ button.button-primary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-list-item-highlight {
|
.panel-list-item-highlight {
|
||||||
background-color: rgba(0, 102, 255, 0.1) !important;
|
background-color: var(--list-item-highlight-bg) !important;
|
||||||
}
|
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
||||||
|
|
||||||
[data-theme="dark"] .panel-list-item-highlight {
|
|
||||||
background-color: rgba(0, 128, 255, 0.2) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-list-item-content {
|
.panel-list-item-content {
|
||||||
@@ -2099,7 +2179,7 @@ button.button-primary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.session-item-base {
|
.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-family: var(--font-family-sans);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
@@ -2110,10 +2190,39 @@ button.button-primary {
|
|||||||
ring-offset-color: var(--surface-secondary);
|
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 {
|
.session-item-active {
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--list-item-highlight-bg);
|
||||||
color: var(--text-inverted);
|
color: var(--text-primary);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-item-inactive {
|
.session-item-inactive {
|
||||||
@@ -2126,7 +2235,8 @@ button.button-primary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.session-item-active .session-item-close:hover {
|
.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 {
|
.session-item-title {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export type {
|
|||||||
Model as SDKModel
|
Model as SDKModel
|
||||||
} from "@opencode-ai/sdk"
|
} from "@opencode-ai/sdk"
|
||||||
|
|
||||||
|
export type SessionStatus = "idle" | "working" | "compacting"
|
||||||
|
|
||||||
// Our client-specific Session interface extending SDK Session
|
// Our client-specific Session interface extending SDK Session
|
||||||
export interface Session extends Omit<import("@opencode-ai/sdk").Session, 'projectID' | 'directory' | 'parentID'> {
|
export interface Session extends Omit<import("@opencode-ai/sdk").Session, 'projectID' | 'directory' | 'parentID'> {
|
||||||
instanceId: string // Client-specific field
|
instanceId: string // Client-specific field
|
||||||
|
|||||||
Reference in New Issue
Block a user