Improve session sidebar UX and tool rendering

This commit is contained in:
Shantur Rathore
2025-11-07 22:53:45 +00:00
parent 5483932196
commit 4b2cb9d212
9 changed files with 419 additions and 203 deletions

View File

@@ -42,6 +42,7 @@ import {
setActiveParentSession, setActiveParentSession,
clearActiveParentSession, clearActiveParentSession,
createSession, createSession,
forkSession,
deleteSession, deleteSession,
getSessionFamily, getSessionFamily,
activeParentSessionId, activeParentSessionId,
@@ -53,6 +54,7 @@ import {
updateSessionModel, updateSessionModel,
agents, agents,
isSessionBusy, isSessionBusy,
getSessionInfo,
} from "./stores/sessions" } from "./stores/sessions"
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"
@@ -61,6 +63,7 @@ import { registerInputShortcuts } from "./lib/shortcuts/input"
import { registerAgentShortcuts } from "./lib/shortcuts/agent" 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"
const SessionView: Component<{ const SessionView: Component<{
sessionId: string sessionId: string
@@ -81,8 +84,27 @@ const SessionView: Component<{
async function handleSendMessage(prompt: string, attachments: Attachment[]) { async function handleSendMessage(prompt: string, attachments: Attachment[]) {
await sendMessage(props.instanceId, props.sessionId, prompt, attachments) await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
} }
function getUserMessageText(messageId: string): string | null {
const currentSession = session()
if (!currentSession) return null
const targetMessage = currentSession.messages.find((m) => m.id === messageId)
const targetInfo = currentSession.messagesInfo.get(messageId)
if (!targetMessage || targetInfo?.role !== "user") {
return null
}
const textParts = targetMessage.parts.filter((p: any) => p.type === "text")
if (textParts.length === 0) {
return null
}
return textParts.map((p: any) => p.text).join("\n")
}
async function handleRevert(messageId: string) { async function handleRevert(messageId: string) {
const instance = instances().get(props.instanceId) const instance = instances().get(props.instanceId)
if (!instance || !instance.client) return if (!instance || !instance.client) return
@@ -94,25 +116,17 @@ const SessionView: Component<{
body: { messageID: messageId }, body: { messageID: messageId },
}) })
// Restore the message to input const restoredText = getUserMessageText(messageId)
const currentSession = session() if (restoredText) {
if (currentSession) { const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
const revertedMessage = currentSession.messages.find((m) => m.id === messageId) if (textarea) {
const revertedInfo = currentSession.messagesInfo.get(messageId) textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
if (revertedMessage && revertedInfo?.role === "user") { textarea.focus()
const textParts = revertedMessage.parts.filter((p: any) => p.type === "text")
if (textParts.length > 0) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textarea) {
textarea.value = textParts.map((p: any) => p.text).join("\n")
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
}
} }
} }
console.log("Reverted to message - UI will update via SSE") console.log("Reverted to message - UI will update via SSE")
} catch (error) { } catch (error) {
console.error("Failed to revert:", error) console.error("Failed to revert:", error)
@@ -120,6 +134,40 @@ const SessionView: Component<{
} }
} }
async function handleFork(messageId?: string) {
if (!messageId) {
console.warn("Fork requires a user message id")
return
}
const restoredText = getUserMessageText(messageId)
try {
const forkedSession = await forkSession(props.instanceId, props.sessionId, { messageId })
const parentToActivate = forkedSession.parentId ?? forkedSession.id
setActiveParentSession(props.instanceId, parentToActivate)
if (forkedSession.parentId) {
setActiveSession(props.instanceId, forkedSession.id)
}
await loadMessages(props.instanceId, forkedSession.id).catch(console.error)
if (restoredText) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textarea) {
textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
}
} catch (error) {
console.error("Failed to fork session:", error)
alert("Failed to fork session")
}
}
return ( return (
<Show <Show
when={session()} when={session()}
@@ -138,6 +186,7 @@ const SessionView: Component<{
messagesInfo={s().messagesInfo} messagesInfo={s().messagesInfo}
revert={s().revert} revert={s().revert}
onRevert={handleRevert} onRevert={handleRevert}
onFork={handleFork}
/> />
<PromptInput <PromptInput
instanceId={props.instanceId} instanceId={props.instanceId}
@@ -152,6 +201,71 @@ const SessionView: Component<{
) )
} }
const formatTokenTotal = (value: number) => {
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(0)}K`
}
return value.toLocaleString()
}
const ContextUsagePanel: Component<{ instanceId: string; sessionId: string }> = (props) => {
const info = createMemo(
() =>
getSessionInfo(props.instanceId, props.sessionId) ?? {
tokens: 0,
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
},
)
const tokens = createMemo(() => info().tokens)
const contextWindow = createMemo(() => info().contextWindow)
const percentage = createMemo(() => {
const windowSize = contextWindow()
if (!windowSize || windowSize <= 0) return null
const percent = Math.round((tokens() / windowSize) * 100)
return Math.min(100, Math.max(0, percent))
})
const costLabel = createMemo(() => {
if (info().isSubscriptionModel || info().cost <= 0) return "Included in plan"
return `$${info().cost.toFixed(2)} spent`
})
return (
<div class="session-context-panel border-r border-base border-b px-3 py-3">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Tokens (last call)</div>
<div class="text-lg font-semibold text-primary">{formatTokenTotal(tokens())}</div>
</div>
<div class="text-xs text-primary/70 text-right leading-tight">{costLabel()}</div>
</div>
<div class="mt-4">
<div class="flex items-center justify-between mb-1">
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Context window usage</div>
<div class="text-sm font-medium text-primary">{percentage() !== null ? `${percentage()}%` : "--"}</div>
</div>
<div class="text-sm text-primary/90">
{contextWindow()
? `${formatTokenTotal(tokens())} of ${formatTokenTotal(contextWindow())}`
: "Window size unavailable"}
</div>
</div>
<div class="mt-3 h-1.5 rounded-full bg-base relative overflow-hidden">
<div
class="absolute inset-y-0 left-0 rounded-full bg-accent-primary transition-[width]"
style={{ width: percentage() === null ? "0%" : `${percentage()}%` }}
/>
</div>
</div>
)
}
const App: Component = () => { const App: Component = () => {
const { isDark } = useTheme() const { isDark } = useTheme()
const commandRegistry = createCommandRegistry() const commandRegistry = createCommandRegistry()
@@ -356,10 +470,10 @@ const App: Component = () => {
commandRegistry.register({ commandRegistry.register({
id: "switch-to-info", id: "switch-to-info",
label: "Switch to Info", label: "Instance Info",
description: "Jump to info view for current instance", description: "Open the instance overview for logs and status",
category: "Session", category: "Instance",
keywords: ["info", "info", "console", "output"], keywords: ["info", "logs", "console", "output"],
shortcut: { key: "L", meta: true, shift: true }, shortcut: { key: "L", meta: true, shift: true },
action: () => { action: () => {
const instance = activeInstance() const instance = activeInstance()
@@ -826,59 +940,55 @@ const App: Component = () => {
class="session-sidebar flex flex-col bg-surface-secondary" class="session-sidebar flex flex-col bg-surface-secondary"
style={{ width: `${sessionSidebarWidth()}px` }} style={{ width: `${sessionSidebarWidth()}px` }}
> >
<SessionList <SessionList
instanceId={instance().id} instanceId={instance().id}
sessions={activeSessions()} sessions={activeSessions()}
activeSessionId={activeSessionIdForInstance()} activeSessionId={activeSessionIdForInstance()}
onSelect={(id) => setActiveSession(instance().id, id)} onSelect={(id) => setActiveSession(instance().id, id)}
onClose={(id) => handleCloseSession(instance().id, id)} onClose={(id) => handleCloseSession(instance().id, id)}
onNew={() => handleNewSession(instance().id)} onNew={() => handleNewSession(instance().id)}
showHeader showHeader
showFooter={false} showFooter={false}
headerContent={ headerContent={
<div class="session-sidebar-header"> <div class="session-sidebar-header">
<span class="session-sidebar-title text-sm font-semibold text-primary">Sessions</span> <span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
<div class="session-sidebar-shortcuts"> <div class="session-sidebar-shortcuts">
{(() => { {(() => {
const shortcut = keyboardRegistry.get("session-prev") const shortcuts = [
return shortcut ? <KeyboardHint shortcuts={[shortcut]} separator="" /> : null keyboardRegistry.get("session-prev"),
})()} keyboardRegistry.get("session-next"),
{(() => { ].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))
const shortcut = keyboardRegistry.get("session-next") return shortcuts.length ? (
return shortcut ? <KeyboardHint shortcuts={[shortcut]} separator="" /> : null <KeyboardHint shortcuts={shortcuts} separator=" " showDescription={false} />
})()} ) : null
</div> })()}
<button </div>
class="session-sidebar-new inline-flex items-center justify-center gap-1.5 rounded-md border border-base px-3 py-2 text-xs font-semibold transition-colors hover:border-accent-primary hover:text-accent-primary" </div>
onClick={() => handleNewSession(instance().id)} }
type="button"
aria-label="Create new session" onWidthChange={setSessionSidebarWidth}
title="New session (Cmd/Ctrl+Shift+N)" />
>
<span class="leading-none">New Session</span>
</button>
</div>
}
onWidthChange={setSessionSidebarWidth}
/>
<div class="session-sidebar-separator border-t border-base" /> <div class="session-sidebar-separator border-t border-base" />
<Show when={activeSessionForInstance()}> <Show when={activeSessionForInstance()}>
{(activeSession) => ( {(activeSession) => (
<div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3"> <>
<AgentSelector <ContextUsagePanel instanceId={instance().id} sessionId={activeSession().id} />
instanceId={instance().id} <div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3">
sessionId={activeSession().id} <AgentSelector
currentAgent={activeSession().agent} instanceId={instance().id}
onAgentChange={handleSidebarAgentChange} sessionId={activeSession().id}
/> currentAgent={activeSession().agent}
<ModelSelector onAgentChange={handleSidebarAgentChange}
instanceId={instance().id} />
sessionId={activeSession().id} <ModelSelector
currentModel={activeSession().model} instanceId={instance().id}
onModelChange={handleSidebarModelChange} sessionId={activeSession().id}
/> currentModel={activeSession().model}
</div> onModelChange={handleSidebarModelChange}
/>
</div>
</>
)} )}
</Show> </Show>
</div> </div>

View File

@@ -104,7 +104,8 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)) setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1))
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
e.preventDefault() e.preventDefault()
setSelectedIndex((i) => Math.max(i - 1, 0)) if (filtered.length === 0) return
setSelectedIndex((i) => (i <= 0 ? filtered.length - 1 : i - 1))
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault() e.preventDefault()
const selected = filtered[selectedIndex()] const selected = filtered[selectedIndex()]

View File

@@ -7,6 +7,7 @@ import HintRow from "./hint-row"
const KeyboardHint: Component<{ const KeyboardHint: Component<{
shortcuts: KeyboardShortcut[] shortcuts: KeyboardShortcut[]
separator?: string separator?: string
showDescription?: boolean
}> = (props) => { }> = (props) => {
function buildShortcutString(shortcut: KeyboardShortcut): string { function buildShortcutString(shortcut: KeyboardShortcut): string {
const parts: string[] = [] const parts: string[] = []
@@ -31,7 +32,7 @@ const KeyboardHint: Component<{
{(shortcut, i) => ( {(shortcut, i) => (
<> <>
{i() > 0 && <span class="mx-1">{props.separator || "•"}</span>} {i() > 0 && <span class="mx-1">{props.separator || "•"}</span>}
<span class="mr-1">{shortcut.description}</span> {props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>}
<Kbd shortcut={buildShortcutString(shortcut)} /> <Kbd shortcut={buildShortcutString(shortcut)} />
</> </>
)} )}

View File

@@ -10,6 +10,7 @@ interface MessageItemProps {
isQueued?: boolean isQueued?: boolean
parts?: any[] parts?: any[]
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
} }
export default function MessageItem(props: MessageItemProps) { export default function MessageItem(props: MessageItemProps) {
@@ -73,12 +74,22 @@ export default function MessageItem(props: MessageItemProps) {
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span> <span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
<Show when={isUser() && props.onRevert}> <Show when={isUser() && props.onRevert}>
<button <button
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-2 py-0.5 rounded text-sm leading-none transition-all duration-200 flex items-center justify-center min-w-7 h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95" class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
onClick={handleRevert} onClick={handleRevert}
title="Revert to this message" title="Revert to this message"
aria-label="Revert to this message" aria-label="Revert to this message"
> >
Revert to
</button>
</Show>
<Show when={isUser() && props.onFork}>
<button
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
onClick={() => props.onFork?.(props.message.id)}
title="Fork from this message"
aria-label="Fork from this message"
>
Fork
</button> </button>
</Show> </Show>
</div> </div>

View File

@@ -89,24 +89,17 @@ function formatTokens(tokens: number): string {
return tokens.toString() return tokens.toString()
} }
// Format session info like TUI (e.g., "110K/73% ($0.42)" or "110K/73%") // Format session info for the session view header
function formatSessionInfo(tokens: number, cost: number, contextWindow: number, isSubscriptionModel: boolean): string { function formatSessionInfo(tokens: number, _cost: number, contextWindow: number, _isSubscriptionModel: boolean): string {
const tokensStr = formatTokens(tokens) const tokensStr = formatTokens(tokens)
// Calculate percentage if we have context window
if (contextWindow > 0) { if (contextWindow > 0) {
const percentage = Math.round((tokens / contextWindow) * 100) const windowStr = formatTokens(contextWindow)
if (isSubscriptionModel) { const percentage = Math.min(100, Math.max(0, Math.round((tokens / contextWindow) * 100)))
return `${tokensStr}/${percentage}%` return `${tokensStr} of ${windowStr} (${percentage}%)`
}
return `${tokensStr}/${percentage}% ($${cost.toFixed(2)})`
} }
// Fallback without context window return tokensStr
if (isSubscriptionModel) {
return tokensStr
}
return `${tokensStr} ($${cost.toFixed(2)})`
} }
interface MessageStreamProps { interface MessageStreamProps {
@@ -122,6 +115,7 @@ interface MessageStreamProps {
} }
loading?: boolean loading?: boolean
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
} }
interface MessageDisplayItem { interface MessageDisplayItem {
@@ -609,7 +603,9 @@ export default function MessageStream(props: MessageStreamProps) {
isQueued={item.isQueued} isQueued={item.isQueued}
parts={item.combinedParts} parts={item.combinedParts}
onRevert={props.onRevert} onRevert={props.onRevert}
onFork={props.onFork}
/> />
) )
} }

View File

@@ -1,8 +1,10 @@
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 } from "../types/session"
import { MessageSquare, Info, Plus, X } from "lucide-solid" import { MessageSquare, Info, X } from "lucide-solid"
import KeyboardHint from "./keyboard-hint" import KeyboardHint from "./keyboard-hint"
import Kbd from "./kbd"
import { keyboardRegistry } from "../lib/keyboard-registry" import { keyboardRegistry } from "../lib/keyboard-registry"
import { formatShortcut } from "../lib/keyboard-utils"
interface SessionListProps { interface SessionListProps {
instanceId: string instanceId: string
@@ -46,6 +48,7 @@ const SessionList: Component<SessionListProps> = (props) => {
const [isResizing, setIsResizing] = createSignal(false) const [isResizing, setIsResizing] = createSignal(false)
const [startX, setStartX] = createSignal(0) const [startX, setStartX] = createSignal(0)
const [startWidth, setStartWidth] = createSignal(DEFAULT_WIDTH) const [startWidth, setStartWidth] = createSignal(DEFAULT_WIDTH)
const infoShortcut = keyboardRegistry.get("switch-to-info")
let mouseMoveHandler: ((event: MouseEvent) => void) | null = null let mouseMoveHandler: ((event: MouseEvent) => void) | null = null
let mouseUpHandler: (() => void) | null = null let mouseUpHandler: (() => void) | null = null
@@ -159,7 +162,7 @@ const SessionList: Component<SessionListProps> = (props) => {
removeTouchListeners() removeTouchListeners()
}) })
const parentSessionIds = createMemo( const userSessionIds = createMemo(
() => { () => {
const ids: string[] = [] const ids: string[] = []
for (const session of props.sessions.values()) { for (const session of props.sessions.values()) {
@@ -167,7 +170,6 @@ const SessionList: Component<SessionListProps> = (props) => {
ids.push(session.id) ids.push(session.id)
} }
} }
ids.push("info")
return ids return ids
}, },
undefined, undefined,
@@ -219,67 +221,73 @@ const SessionList: Component<SessionListProps> = (props) => {
</Show> </Show>
<div class="session-list flex-1 overflow-y-auto"> <div class="session-list flex-1 overflow-y-auto">
<div class="session-section"> <div class="session-section">
<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 Session & Info Instance
</div>
<div class="session-list-item group">
<button
class={`session-item-base ${props.activeSessionId === "info" ? "session-item-active" : "session-item-inactive"}`}
onClick={() => props.onSelect("info")}
title="Instance Info"
role="button"
aria-selected={props.activeSessionId === "info"}
>
<div class="flex items-center gap-2 flex-1">
<Info class="w-4 h-4 flex-shrink-0" />
<span class="session-item-title truncate">Instance Info</span>
</div>
{infoShortcut && <Kbd shortcut={formatShortcut(infoShortcut)} class="ml-2 not-italic" />}
</button>
</div>
</div> </div>
<For each={parentSessionIds()}>
{(id) => {
if (id === "info") { <Show when={userSessionIds().length > 0}>
const isActive = () => props.activeSessionId === "info" <div class="session-section">
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
User Sessions
</div>
<For each={userSessionIds()}>
{(id) => {
const session = () => props.sessions.get(id)
if (!session()) {
return null
}
const isActive = () => props.activeSessionId === id
const title = () => session()?.title || "Untitled"
return ( return (
<div class="session-list-item group"> <div class="session-list-item group">
<button <button
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"} session-item-special`} class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
onClick={() => props.onSelect("info")} onClick={() => props.onSelect(id)}
title="Info" title={title()}
role="button" role="button"
aria-selected={isActive()} aria-selected={isActive()}
> >
<Info class="w-4 h-4 flex-shrink-0" /> <MessageSquare class="w-4 h-4 flex-shrink-0" />
<span class="session-item-title truncate">Info</span> <span class="session-item-title truncate">{title()}</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>
</button> </button>
</div> </div>
) )
} }}
</For>
const session = () => props.sessions.get(id) </div>
if (!session()) { </Show>
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={() => props.onSelect(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>
<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>
</button>
</div>
)
}}
</For>
</div>
<Show when={childSessionIds().length > 0}> <Show when={childSessionIds().length > 0}>
<div class="session-section"> <div class="session-section">
@@ -318,17 +326,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<Show when={props.showFooter !== false}> <Show when={props.showFooter !== false}>
<div class="session-list-footer p-3 border-t border-base"> <div class="session-list-footer p-3 border-t border-base">
{props.footerContent ?? ( {props.footerContent ?? null}
<button
class="session-new-button w-full flex items-center gap-2 px-3 py-2 rounded-md transition-colors text-sm font-medium"
onClick={props.onNew}
title="New session (Cmd/Ctrl+T)"
aria-label="New session"
>
<Plus class="w-4 h-4" />
<span>New Session</span>
</button>
)}
</div> </div>
</Show> </Show>
</div> </div>

View File

@@ -136,12 +136,29 @@ export default function ToolCall(props: ToolCallProps) {
const expanded = () => isToolCallExpanded(toolCallId()) const expanded = () => isToolCallExpanded(toolCallId())
const [initializedId, setInitializedId] = createSignal<string | null>(null) const [initializedId, setInitializedId] = createSignal<string | null>(null)
let markdownContainerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined
const handleMarkdownRendered = () => { const handleScrollRendered = () => {
const id = toolCallId() const id = toolCallId()
if (!id || !markdownContainerRef) return if (!id || !scrollContainerRef) return
restoreScrollState(id, markdownContainerRef) restoreScrollState(id, scrollContainerRef)
}
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
const resolvedElement = element || undefined
scrollContainerRef = resolvedElement
const id = toolCallId()
if (!resolvedElement || !id) return
if (!toolScrollState.has(id)) {
requestAnimationFrame(() => {
if (!scrollContainerRef || toolCallId() !== id) return
scrollContainerRef.scrollTop = scrollContainerRef.scrollHeight
updateScrollState(id, scrollContainerRef)
})
} else {
restoreScrollState(id, resolvedElement)
}
} }
createEffect(() => { createEffect(() => {
@@ -165,6 +182,15 @@ export default function ToolCall(props: ToolCallProps) {
}) })
}) })
createEffect(() => {
if (props.toolCall?.tool !== "task") return
const summarySignature = JSON.stringify(props.toolCall?.state?.metadata?.summary ?? [])
requestAnimationFrame(() => {
void summarySignature
handleScrollRendered()
})
})
const statusIcon = () => { const statusIcon = () => {
const status = props.toolCall?.state?.status || "" const status = props.toolCall?.state?.status || ""
switch (status) { switch (status) {
@@ -348,28 +374,14 @@ export default function ToolCall(props: ToolCallProps) {
return ( return (
<div <div
class={messageClass} class={messageClass}
ref={(element) => { ref={(element) => initializeScrollContainer(element)}
markdownContainerRef = element || undefined
const id = toolCallId()
if (!element || !id) return
if (!toolScrollState.has(id)) {
requestAnimationFrame(() => {
if (!markdownContainerRef || toolCallId() !== id) return
markdownContainerRef.scrollTop = markdownContainerRef.scrollHeight
updateScrollState(id, markdownContainerRef)
})
} else {
restoreScrollState(id, element)
}
}}
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)} onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
> >
<Markdown <Markdown
part={markdownPart} part={markdownPart}
isDark={isDark()} isDark={isDark()}
disableHighlight={disableHighlight} disableHighlight={disableHighlight}
onRendered={handleMarkdownRendered} onRendered={handleScrollRendered}
/> />
</div> </div>
) )
@@ -537,34 +549,40 @@ export default function ToolCall(props: ToolCallProps) {
} }
return ( return (
<div class="tool-call-task-summary"> <div
<For each={summary}> class="message-text tool-call-markdown tool-call-markdown-large tool-call-task-container"
{(item) => { ref={(element) => initializeScrollContainer(element)}
const tool = item.tool || "unknown" onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
const itemInput = item.state?.input || {} >
const icon = getToolIcon(tool) <div class="tool-call-task-summary">
<For each={summary}>
{(item) => {
const tool = item.tool || "unknown"
const itemInput = item.state?.input || {}
const icon = getToolIcon(tool)
let description = "" let description = ""
switch (tool) { switch (tool) {
case "bash": case "bash":
description = itemInput.description || itemInput.command || "" description = itemInput.description || itemInput.command || ""
break break
case "edit": case "edit":
case "read": case "read":
case "write": case "write":
description = `${tool} ${getRelativePath(itemInput.filePath || "")}` description = `${tool} ${getRelativePath(itemInput.filePath || "")}`
break break
default: default:
description = tool description = tool
} }
return ( return (
<div class="tool-call-task-item"> <div class="tool-call-task-item">
{icon} {description} {icon} {description}
</div> </div>
) )
}} }}
</For> </For>
</div>
</div> </div>
) )
} }

View File

@@ -581,6 +581,87 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
} }
} }
async function forkSession(
instanceId: string,
sourceSessionId: string,
options?: { messageId?: string },
): Promise<Session> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const request: {
path: { id: string }
body?: { messageID: string }
} = {
path: { id: sourceSessionId },
}
if (options?.messageId) {
request.body = { messageID: options.messageId }
}
const response = await instance.client.session.fork(request)
if (!response.data) {
throw new Error("Failed to fork session: No data returned")
}
const info = response.data
const forkedSession: Session = {
id: info.id,
instanceId,
title: info.title || "Forked Session",
parentId: info.parentID || null,
agent: info.agent || "",
model: {
providerId: info.model?.providerID || "",
modelId: info.model?.modelID || "",
},
time: {
created: info.time?.created || Date.now(),
updated: info.time?.updated || Date.now(),
},
revert: info.revert
? {
messageID: info.revert.messageID,
partID: info.revert.partID,
snapshot: info.revert.snapshot,
diff: info.revert.diff,
}
: undefined,
messages: [],
messagesInfo: new Map(),
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId) || new Map()
instanceSessions.set(forkedSession.id, forkedSession)
next.set(instanceId, instanceSessions)
return next
})
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(forkedSession.id, {
tokens: 0,
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
})
next.set(instanceId, instanceInfo)
return next
})
getSessionIndex(instanceId, forkedSession.id)
return forkedSession
}
async function deleteSession(instanceId: string, sessionId: string): Promise<void> { async function deleteSession(instanceId: string, sessionId: string): Promise<void> {
const instance = instances().get(instanceId) const instance = instances().get(instanceId)
if (!instance || !instance.client) { if (!instance || !instance.client) {
@@ -1614,6 +1695,7 @@ export {
getSessionInfo, getSessionInfo,
fetchSessions, fetchSessions,
createSession, createSession,
forkSession,
deleteSession, deleteSession,
fetchAgents, fetchAgents,
fetchProviders, fetchProviders,

View File

@@ -907,6 +907,10 @@ button.button-primary {
background-color: rgba(0, 128, 255, 0.22); background-color: rgba(0, 128, 255, 0.22);
} }
.tool-call-task-container {
padding: 12px;
}
.tool-call-task-summary { .tool-call-task-summary {
@apply my-2 flex flex-col gap-1.5; @apply my-2 flex flex-col gap-1.5;
} }
@@ -1825,11 +1829,6 @@ button.button-primary {
color: var(--text-primary); color: var(--text-primary);
} }
.session-item-special {
color: var(--text-muted);
font-style: italic;
}
.session-item-active .session-item-close:hover { .session-item-active .session-item-close:hover {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
} }