diff --git a/src/App.tsx b/src/App.tsx index 75b693e9..0cc2893c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,7 @@ import { setActiveParentSession, clearActiveParentSession, createSession, + forkSession, deleteSession, getSessionFamily, activeParentSessionId, @@ -53,6 +54,7 @@ import { updateSessionModel, agents, isSessionBusy, + getSessionInfo, } from "./stores/sessions" import { setupTabKeyboardShortcuts } from "./lib/keyboard" 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 { registerEscapeShortcut, setEscapeStateChangeHandler } from "./lib/shortcuts/escape" import { keyboardRegistry } from "./lib/keyboard-registry" +import type { KeyboardShortcut } from "./lib/keyboard-registry" const SessionView: Component<{ sessionId: string @@ -81,8 +84,27 @@ const SessionView: Component<{ async function handleSendMessage(prompt: string, attachments: Attachment[]) { 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) { + const instance = instances().get(props.instanceId) if (!instance || !instance.client) return @@ -94,25 +116,17 @@ const SessionView: Component<{ body: { messageID: messageId }, }) - // Restore the message to input - const currentSession = session() - if (currentSession) { - const revertedMessage = currentSession.messages.find((m) => m.id === messageId) - const revertedInfo = currentSession.messagesInfo.get(messageId) - - if (revertedMessage && revertedInfo?.role === "user") { - 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() - } - } + const restoredText = getUserMessageText(messageId) + if (restoredText) { + const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement + if (textarea) { + textarea.value = restoredText + textarea.dispatchEvent(new Event("input", { bubbles: true })) + textarea.focus() } } + console.log("Reverted to message - UI will update via SSE") } catch (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 ( { + 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 ( +
+
+
+
Tokens (last call)
+
{formatTokenTotal(tokens())}
+
+
{costLabel()}
+
+
+
+
Context window usage
+
{percentage() !== null ? `${percentage()}%` : "--"}
+
+
+ {contextWindow() + ? `${formatTokenTotal(tokens())} of ${formatTokenTotal(contextWindow())}` + : "Window size unavailable"} +
+
+
+
+
+
+ ) +} + const App: Component = () => { const { isDark } = useTheme() const commandRegistry = createCommandRegistry() @@ -356,10 +470,10 @@ const App: Component = () => { commandRegistry.register({ id: "switch-to-info", - label: "Switch to Info", - description: "Jump to info view for current instance", - category: "Session", - keywords: ["info", "info", "console", "output"], + label: "Instance Info", + description: "Open the instance overview for logs and status", + category: "Instance", + keywords: ["info", "logs", "console", "output"], shortcut: { key: "L", meta: true, shift: true }, action: () => { const instance = activeInstance() @@ -826,59 +940,55 @@ const App: Component = () => { class="session-sidebar flex flex-col bg-surface-secondary" style={{ width: `${sessionSidebarWidth()}px` }} > - setActiveSession(instance().id, id)} - onClose={(id) => handleCloseSession(instance().id, id)} - onNew={() => handleNewSession(instance().id)} - showHeader - showFooter={false} - headerContent={ -
- Sessions -
- {(() => { - const shortcut = keyboardRegistry.get("session-prev") - return shortcut ? : null - })()} - {(() => { - const shortcut = keyboardRegistry.get("session-next") - return shortcut ? : null - })()} -
- -
- } + setActiveSession(instance().id, id)} + onClose={(id) => handleCloseSession(instance().id, id)} + onNew={() => handleNewSession(instance().id)} + showHeader + showFooter={false} + headerContent={ +
+ Sessions +
+ {(() => { + const shortcuts = [ + keyboardRegistry.get("session-prev"), + keyboardRegistry.get("session-next"), + ].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut)) + return shortcuts.length ? ( + + ) : null + })()} +
+
+ } + + onWidthChange={setSessionSidebarWidth} + /> - onWidthChange={setSessionSidebarWidth} - />
{(activeSession) => ( -
- - -
+ <> + +
+ + +
+ )}
diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx index f9fa7b39..63c80a41 100644 --- a/src/components/command-palette.tsx +++ b/src/components/command-palette.tsx @@ -104,7 +104,8 @@ const CommandPalette: Component = (props) => { setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)) } else if (e.key === "ArrowUp") { 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") { e.preventDefault() const selected = filtered[selectedIndex()] diff --git a/src/components/keyboard-hint.tsx b/src/components/keyboard-hint.tsx index bba8177a..f68e6de4 100644 --- a/src/components/keyboard-hint.tsx +++ b/src/components/keyboard-hint.tsx @@ -7,6 +7,7 @@ import HintRow from "./hint-row" const KeyboardHint: Component<{ shortcuts: KeyboardShortcut[] separator?: string + showDescription?: boolean }> = (props) => { function buildShortcutString(shortcut: KeyboardShortcut): string { const parts: string[] = [] @@ -31,7 +32,7 @@ const KeyboardHint: Component<{ {(shortcut, i) => ( <> {i() > 0 && {props.separator || "•"}} - {shortcut.description} + {props.showDescription !== false && {shortcut.description}} )} diff --git a/src/components/message-item.tsx b/src/components/message-item.tsx index dfccfaa5..39342e27 100644 --- a/src/components/message-item.tsx +++ b/src/components/message-item.tsx @@ -10,6 +10,7 @@ interface MessageItemProps { isQueued?: boolean parts?: any[] onRevert?: (messageId: string) => void + onFork?: (messageId?: string) => void } export default function MessageItem(props: MessageItemProps) { @@ -73,12 +74,22 @@ export default function MessageItem(props: MessageItemProps) { {timestamp()} + + +
diff --git a/src/components/message-stream.tsx b/src/components/message-stream.tsx index 3aa005e5..fd977664 100644 --- a/src/components/message-stream.tsx +++ b/src/components/message-stream.tsx @@ -89,24 +89,17 @@ function formatTokens(tokens: number): string { return tokens.toString() } -// Format session info like TUI (e.g., "110K/73% ($0.42)" or "110K/73%") -function formatSessionInfo(tokens: number, cost: number, contextWindow: number, isSubscriptionModel: boolean): string { +// Format session info for the session view header +function formatSessionInfo(tokens: number, _cost: number, contextWindow: number, _isSubscriptionModel: boolean): string { const tokensStr = formatTokens(tokens) - // Calculate percentage if we have context window if (contextWindow > 0) { - const percentage = Math.round((tokens / contextWindow) * 100) - if (isSubscriptionModel) { - return `${tokensStr}/${percentage}%` - } - return `${tokensStr}/${percentage}% ($${cost.toFixed(2)})` + const windowStr = formatTokens(contextWindow) + const percentage = Math.min(100, Math.max(0, Math.round((tokens / contextWindow) * 100))) + return `${tokensStr} of ${windowStr} (${percentage}%)` } - // Fallback without context window - if (isSubscriptionModel) { - return tokensStr - } - return `${tokensStr} ($${cost.toFixed(2)})` + return tokensStr } interface MessageStreamProps { @@ -122,6 +115,7 @@ interface MessageStreamProps { } loading?: boolean onRevert?: (messageId: string) => void + onFork?: (messageId?: string) => void } interface MessageDisplayItem { @@ -609,7 +603,9 @@ export default function MessageStream(props: MessageStreamProps) { isQueued={item.isQueued} parts={item.combinedParts} onRevert={props.onRevert} + onFork={props.onFork} /> + ) } diff --git a/src/components/session-list.tsx b/src/components/session-list.tsx index 1327e6b9..e60c28cb 100644 --- a/src/components/session-list.tsx +++ b/src/components/session-list.tsx @@ -1,8 +1,10 @@ import { Component, For, Show, createSignal, createEffect, onCleanup, onMount, createMemo, JSX } from "solid-js" 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 Kbd from "./kbd" import { keyboardRegistry } from "../lib/keyboard-registry" +import { formatShortcut } from "../lib/keyboard-utils" interface SessionListProps { instanceId: string @@ -46,6 +48,7 @@ const SessionList: Component = (props) => { const [isResizing, setIsResizing] = createSignal(false) const [startX, setStartX] = createSignal(0) const [startWidth, setStartWidth] = createSignal(DEFAULT_WIDTH) + const infoShortcut = keyboardRegistry.get("switch-to-info") let mouseMoveHandler: ((event: MouseEvent) => void) | null = null let mouseUpHandler: (() => void) | null = null @@ -159,7 +162,7 @@ const SessionList: Component = (props) => { removeTouchListeners() }) - const parentSessionIds = createMemo( + const userSessionIds = createMemo( () => { const ids: string[] = [] for (const session of props.sessions.values()) { @@ -167,7 +170,6 @@ const SessionList: Component = (props) => { ids.push(session.id) } } - ids.push("info") return ids }, undefined, @@ -219,67 +221,73 @@ const SessionList: Component = (props) => {
-
-
- User Session & Info +
+
+ Instance +
+
+ +
- - {(id) => { - if (id === "info") { - const isActive = () => props.activeSessionId === "info" + + + 0}> +
+
+ User Sessions +
+ + {(id) => { + const session = () => props.sessions.get(id) + if (!session()) { + return null + } + + const isActive = () => props.activeSessionId === id + const title = () => session()?.title || "Untitled" + return (
) - } - - const session = () => props.sessions.get(id) - if (!session()) { - return null - } - - const isActive = () => props.activeSessionId === id - const title = () => session()?.title || "Untitled" - - return ( -
- -
- ) - }} -
-
+ }} +
+
+ 0}>
@@ -318,17 +326,7 @@ const SessionList: Component = (props) => {
diff --git a/src/components/tool-call.tsx b/src/components/tool-call.tsx index 42043507..501ea845 100644 --- a/src/components/tool-call.tsx +++ b/src/components/tool-call.tsx @@ -136,12 +136,29 @@ export default function ToolCall(props: ToolCallProps) { const expanded = () => isToolCallExpanded(toolCallId()) const [initializedId, setInitializedId] = createSignal(null) - let markdownContainerRef: HTMLDivElement | undefined + let scrollContainerRef: HTMLDivElement | undefined - const handleMarkdownRendered = () => { + const handleScrollRendered = () => { const id = toolCallId() - if (!id || !markdownContainerRef) return - restoreScrollState(id, markdownContainerRef) + if (!id || !scrollContainerRef) return + 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(() => { @@ -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 status = props.toolCall?.state?.status || "" switch (status) { @@ -348,28 +374,14 @@ export default function ToolCall(props: ToolCallProps) { return (
{ - 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) - } - }} + ref={(element) => initializeScrollContainer(element)} onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)} >
) @@ -537,34 +549,40 @@ export default function ToolCall(props: ToolCallProps) { } return ( -
- - {(item) => { - const tool = item.tool || "unknown" - const itemInput = item.state?.input || {} - const icon = getToolIcon(tool) +
initializeScrollContainer(element)} + onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)} + > +
+ + {(item) => { + const tool = item.tool || "unknown" + const itemInput = item.state?.input || {} + const icon = getToolIcon(tool) - let description = "" - switch (tool) { - case "bash": - description = itemInput.description || itemInput.command || "" - break - case "edit": - case "read": - case "write": - description = `${tool} ${getRelativePath(itemInput.filePath || "")}` - break - default: - description = tool - } + let description = "" + switch (tool) { + case "bash": + description = itemInput.description || itemInput.command || "" + break + case "edit": + case "read": + case "write": + description = `${tool} ${getRelativePath(itemInput.filePath || "")}` + break + default: + description = tool + } - return ( -
- {icon} {description} -
- ) - }} -
+ return ( +
+ {icon} {description} +
+ ) + }} + +
) } diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index afe47b36..cf84cd91 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -581,6 +581,87 @@ async function createSession(instanceId: string, agent?: string): Promise { + 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 { const instance = instances().get(instanceId) if (!instance || !instance.client) { @@ -1614,6 +1695,7 @@ export { getSessionInfo, fetchSessions, createSession, + forkSession, deleteSession, fetchAgents, fetchProviders, diff --git a/src/styles/components.css b/src/styles/components.css index 874810b8..5cac40f6 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -907,6 +907,10 @@ button.button-primary { background-color: rgba(0, 128, 255, 0.22); } +.tool-call-task-container { + padding: 12px; +} + .tool-call-task-summary { @apply my-2 flex flex-col gap-1.5; } @@ -1825,11 +1829,6 @@ button.button-primary { color: var(--text-primary); } -.session-item-special { - color: var(--text-muted); - font-style: italic; -} - .session-item-active .session-item-close:hover { background-color: rgba(255, 255, 255, 0.2); }