Improve session sidebar UX and tool rendering
This commit is contained in:
@@ -104,7 +104,8 @@ const CommandPalette: Component<CommandPaletteProps> = (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()]
|
||||
|
||||
@@ -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 && <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)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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) {
|
||||
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
|
||||
<Show when={isUser() && props.onRevert}>
|
||||
<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}
|
||||
title="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>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SessionListProps> = (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<SessionListProps> = (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<SessionListProps> = (props) => {
|
||||
ids.push(session.id)
|
||||
}
|
||||
}
|
||||
ids.push("info")
|
||||
return ids
|
||||
},
|
||||
undefined,
|
||||
@@ -219,67 +221,73 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<div class="session-list flex-1 overflow-y-auto">
|
||||
<div class="session-section">
|
||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||
User Session & Info
|
||||
<div class="session-section">
|
||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||
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>
|
||||
<For each={parentSessionIds()}>
|
||||
{(id) => {
|
||||
if (id === "info") {
|
||||
const isActive = () => props.activeSessionId === "info"
|
||||
|
||||
|
||||
<Show when={userSessionIds().length > 0}>
|
||||
<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 (
|
||||
<div class="session-list-item group">
|
||||
<button
|
||||
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"} session-item-special`}
|
||||
onClick={() => props.onSelect("info")}
|
||||
title="Info"
|
||||
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||
onClick={() => props.onSelect(id)}
|
||||
title={title()}
|
||||
role="button"
|
||||
aria-selected={isActive()}
|
||||
>
|
||||
<Info class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="session-item-title truncate">Info</span>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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={() => 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>
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={childSessionIds().length > 0}>
|
||||
<div class="session-section">
|
||||
@@ -318,17 +326,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
|
||||
<Show when={props.showFooter !== false}>
|
||||
<div class="session-list-footer p-3 border-t border-base">
|
||||
{props.footerContent ?? (
|
||||
<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>
|
||||
)}
|
||||
{props.footerContent ?? null}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -136,12 +136,29 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const expanded = () => isToolCallExpanded(toolCallId())
|
||||
const [initializedId, setInitializedId] = createSignal<string | null>(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 (
|
||||
<div
|
||||
class={messageClass}
|
||||
ref={(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)
|
||||
}
|
||||
}}
|
||||
ref={(element) => initializeScrollContainer(element)}
|
||||
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
|
||||
>
|
||||
<Markdown
|
||||
part={markdownPart}
|
||||
isDark={isDark()}
|
||||
disableHighlight={disableHighlight}
|
||||
onRendered={handleMarkdownRendered}
|
||||
onRendered={handleScrollRendered}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -537,34 +549,40 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={summary}>
|
||||
{(item) => {
|
||||
const tool = item.tool || "unknown"
|
||||
const itemInput = item.state?.input || {}
|
||||
const icon = getToolIcon(tool)
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-task-container"
|
||||
ref={(element) => initializeScrollContainer(element)}
|
||||
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
|
||||
>
|
||||
<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 = ""
|
||||
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 (
|
||||
<div class="tool-call-task-item">
|
||||
{icon} {description}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
return (
|
||||
<div class="tool-call-task-item">
|
||||
{icon} {description}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user