Improve session sidebar UX and tool rendering
This commit is contained in:
250
src/App.tsx
250
src/App.tsx
@@ -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>
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|||||||
@@ -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)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user