Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/server/src/config/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { PreferencesSchema } from "./schema"

describe("chat visibility preferences", () => {
it("accepts hidden diagnostics and collapsed usage metrics", () => {
const preferences = PreferencesSchema.parse({
diagnosticsExpansion: "hidden",
usageMetricsExpansion: "collapsed",
})

assert.equal(preferences.diagnosticsExpansion, "hidden")
assert.equal(preferences.usageMetricsExpansion, "collapsed")
})
})
3 changes: 2 additions & 1 deletion packages/server/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ const PreferencesSchema = z
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["hidden", "expanded", "collapsed"]).default("expanded"),
showUsageMetrics: z.boolean().default(true),
usageMetricsExpansion: z.enum(["expanded", "collapsed"]).default("collapsed"),
autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"),
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
Expand Down
268 changes: 15 additions & 253 deletions packages/ui/src/components/folder-selection-view.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/ui/src/components/instance-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {

<button
class="new-tab-button"
onClick={() => openSettings("appearance")}
onClick={() => openSettings("general")}
title={t("settings.open.title")}
aria-label={t("settings.open.ariaLabel")}
>
Expand Down
69 changes: 69 additions & 0 deletions packages/ui/src/components/locale-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Select } from "@kobalte/core/select"
import { ChevronDown, Languages } from "lucide-solid"
import type { Component } from "solid-js"
import { useI18n, type Locale } from "../lib/i18n"
import { useConfig } from "../stores/preferences"

type LanguageOption = { value: Locale; label: string }

const languageOptions: LanguageOption[] = [
{ value: "en", label: "English" },
{ value: "es", label: "Español" },
{ value: "fr", label: "Français" },
{ value: "ru", label: "Русский" },
{ value: "ja", label: "日本語" },
{ value: "zh-Hans", label: "简体中文" },
{ value: "he", label: "עברית" },
{ value: "de", label: "Deutsch" },
{ value: "ne", label: "नेपाली" },
]

export const LocaleSelector: Component = () => {
const { t, locale } = useI18n()
const { updatePreferences } = useConfig()
const selectedOption = () => languageOptions.find((option) => option.value === locale()) ?? languageOptions[0]

return (
<Select<LanguageOption>
value={selectedOption()}
onChange={(option) => {
if (!option || option.value === locale()) return
updatePreferences({ locale: option.value })
}}
options={languageOptions}
optionValue="value"
optionTextValue="label"
itemComponent={(itemProps) => (
<Select.Item item={itemProps.item} class="selector-option">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
</Select.Item>
)}
>
<Select.Trigger
class="selector-trigger"
aria-label={t("folderSelection.language.ariaLabel")}
title={t("folderSelection.language.ariaLabel")}
>
<Languages class="w-4 h-4 icon-muted" aria-hidden="true" />
<div class="flex-1 min-w-0">
<Select.Value<LanguageOption>>
{(state) => (
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{state.selectedOption()?.label}
</span>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>
</Select.Trigger>

<Select.Portal>
<Select.Content class="selector-popover min-w-[180px]">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
)
}
79 changes: 61 additions & 18 deletions packages/ui/src/components/message-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { SessionSearchMatch } from "../lib/session-search"
import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overflow-menu"
import { copyToClipboard } from "../lib/clipboard"
import SpeechActionButton from "./speech-action-button"
import type { VisibilityPreference } from "../stores/preferences"

function DeleteUpToIcon() {
return (
Expand Down Expand Up @@ -633,7 +634,8 @@ interface MessageBlockProps {
lastAssistantIndex: () => number
showThinking: () => boolean
thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean
usageMetricsVisibility: () => VisibilityPreference
toolVisibility: (toolName: string) => VisibilityPreference
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: () => Set<string>
Expand Down Expand Up @@ -720,7 +722,7 @@ export default function MessageBlock(props: MessageBlockProps) {
isQueued ? 1 : 0,
props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0,
props.showUsageMetrics() ? 1 : 0,
props.usageMetricsVisibility(),
].join("|")

const cachedBlock = sessionCache.messageBlocks.get(current.id)
Expand Down Expand Up @@ -828,7 +830,7 @@ export default function MessageBlock(props: MessageBlockProps) {

if (part.type === "step-finish") {
flushContent()
if (props.showUsageMetrics()) {
if (props.usageMetricsVisibility() !== "hidden") {
const key = `${current.id}:${part.id ?? partIndex}:${part.type}`
const accentColor = lastAccentColor || defaultAccentColor
items.push({ type: part.type, key, part, messageInfo: info, accentColor })
Expand Down Expand Up @@ -890,9 +892,23 @@ export default function MessageBlock(props: MessageBlockProps) {
return resultBlock
})

const isDisplayItemVisible = (item: MessageBlockItem) => {
if (item.type !== "tool") return true
const part = props.store().getMessage(item.messageId)?.parts[item.partId]?.data
if (part?.type !== "tool" || props.toolVisibility(part.tool || "") !== "hidden") return true
return Boolean(
part.pendingPermission?.active ||
props.store().getPermissionState(item.messageId, item.partId)?.active ||
props.store().getQuestionState(item.messageId, item.partId)?.active,
)
}

const firstVisibleItemIndex = () => (block()?.items ?? []).findIndex(isDisplayItemVisible)

return (
<Show when={block()}>
{(resolvedBlock) => (
<Show when={resolvedBlock().items.some(isDisplayItemVisible)}>
<div
ref={(el) => {
blockRef = el
Expand All @@ -915,7 +931,7 @@ export default function MessageBlock(props: MessageBlockProps) {
startPartId={(item() as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex}
showDeleteMessage={index === 0}
showDeleteMessage={index === firstVisibleItemIndex()}
onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
Expand All @@ -929,14 +945,15 @@ export default function MessageBlock(props: MessageBlockProps) {
{(() => {
const toolItem = item() as ToolDisplayItem
return (
<Show when={isDisplayItemVisible(toolItem)}>
<div class="tool-call-message" data-key={toolItem.key} data-part-id={toolItem.partId}>
<ToolCallItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
showDeleteMessage={index === 0}
showDeleteMessage={index === firstVisibleItemIndex()}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
Expand All @@ -946,6 +963,7 @@ export default function MessageBlock(props: MessageBlockProps) {
onContentRendered={props.onContentRendered}
/>
</div>
</Show>
)
})()}
</Match>
Expand All @@ -955,7 +973,7 @@ export default function MessageBlock(props: MessageBlockProps) {
part={(item() as StepDisplayItem).part}
messageInfo={(item() as StepDisplayItem).messageInfo}
showAgentMeta
showDeleteMessage={index === 0}
showDeleteMessage={index === firstVisibleItemIndex()}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
Expand All @@ -971,9 +989,9 @@ export default function MessageBlock(props: MessageBlockProps) {
kind="finish"
part={(item() as StepDisplayItem).part}
messageInfo={(item() as StepDisplayItem).messageInfo}
showUsage={props.showUsageMetrics()}
usageVisibility={props.usageMetricsVisibility()}
borderColor={(item() as StepDisplayItem).accentColor}
showDeleteMessage={index === 0}
showDeleteMessage={index === firstVisibleItemIndex()}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
Expand All @@ -993,7 +1011,7 @@ export default function MessageBlock(props: MessageBlockProps) {
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={(item() as CompactionDisplayItem).messageId}
showDeleteMessage={index === 0}
showDeleteMessage={index === firstVisibleItemIndex()}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
Expand All @@ -1010,7 +1028,7 @@ export default function MessageBlock(props: MessageBlockProps) {
messageId={(item() as ReasoningDisplayItem).messageId}
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index === 0}
showDeleteMessage={index === firstVisibleItemIndex()}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
Expand All @@ -1024,6 +1042,7 @@ export default function MessageBlock(props: MessageBlockProps) {
)}
</Index>
</div>
</Show>
)}
</Show>
)
Expand All @@ -1034,7 +1053,7 @@ interface StepCardProps {
part: ClientPart
messageInfo?: MessageInfo
showAgentMeta?: boolean
showUsage?: boolean
usageVisibility?: VisibilityPreference
borderColor?: string
showDeleteMessage?: boolean
instanceId?: string
Expand Down Expand Up @@ -1171,6 +1190,15 @@ function StepCard(props: StepCardProps) {
const { t } = useI18n()
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const [usageExpandedOverride, setUsageExpandedOverride] = createSignal<boolean | null>(null)
const usageExpanded = () => usageExpandedOverride() ?? props.usageVisibility === "expanded"
let usagePartId = (props.part as { id?: string }).id
createEffect(() => {
const nextPartId = (props.part as { id?: string }).id
if (nextPartId === usagePartId) return
usagePartId = nextPartId
setUsageExpandedOverride(null)
})
const isSelectedForDeletion = () => Boolean(props.messageId && props.selectedMessageIds?.().has(props.messageId))
const isSelectedCompanion = () => {
const partId = (props.part as { id?: unknown })?.id
Expand Down Expand Up @@ -1204,7 +1232,7 @@ function StepCard(props: StepCardProps) {
}

const usageStats = () => {
if (props.kind !== "finish" || !props.showUsage) {
if (props.kind !== "finish" || props.usageVisibility === "hidden") {
return null
}
const info = props.messageInfo
Expand Down Expand Up @@ -1315,16 +1343,22 @@ function StepCard(props: StepCardProps) {
}


const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = [
const usageEntries = (usage: NonNullable<ReturnType<typeof usageStats>>) => [
{ label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.output"), value: usage.output, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.reasoning"), value: usage.reasoning, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.cacheRead"), value: usage.cacheRead, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.cacheWrite"), value: usage.cacheWrite, formatter: formatTokenTotal },
...(usageExpanded()
? [
{ label: t("messageBlock.usage.reasoning"), value: usage.reasoning, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.cacheRead"), value: usage.cacheRead, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.cacheWrite"), value: usage.cacheWrite, formatter: formatTokenTotal },
]
: []),
{ label: t("messageBlock.usage.cost"), value: usage.cost, formatter: formatCostValue },
]

const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = usageEntries(usage)

return (
<div class="message-step-usage">
<For each={entries}>
Expand Down Expand Up @@ -1360,7 +1394,16 @@ function StepCard(props: StepCardProps) {
</div>
</Show>

{renderUsageChips(usage)}
<button
type="button"
class="message-step-usage-toggle"
aria-expanded={usageExpanded()}
aria-label={`${t(usageExpanded() ? "messageBlock.usage.collapseAriaLabel" : "messageBlock.usage.expandAriaLabel")}. ${usageEntries(usage).map((entry) => `${entry.label}: ${entry.formatter(entry.value)}`).join(", ")}`}
onClick={() => setUsageExpandedOverride((current) => !(current ?? props.usageVisibility === "expanded"))}
>
<span class="message-step-usage-disclosure" aria-hidden="true">{usageExpanded() ? "▼" : "▶"}</span>
{renderUsageChips(usage)}
</button>
</div>
)
}
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/components/message-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
lastAssistantIndex={lastAssistantIndex}
showThinking={() => false}
thinkingDefaultExpanded={() => false}
showUsageMetrics={() => false}
usageMetricsVisibility={() => "hidden"}
toolVisibility={() => "expanded"}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
Expand Down
12 changes: 7 additions & 5 deletions packages/ui/src/components/message-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { buildRecordDisplayData } from "../stores/message-v2/record-display-cach
import { getPartCharCount } from "../lib/token-utils"
import { buildSessionSearchMatches } from "../lib/session-search"
import type { SessionSearchMatch } from "../lib/session-search"
import { resolveThinkingExpansionDefault } from "./tool-call/tool-registry"
import { resolveThinkingExpansionDefault, resolveToolVisibility } from "./tool-call/tool-registry"
import { collectToolDeletionCompanionPartIds, executeBulkDeletionPlan } from "./tool-deletion-companions"

const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
Expand Down Expand Up @@ -57,7 +57,8 @@ export interface MessageSectionProps {
export default function MessageSection(props: MessageSectionProps) {
const { preferences, updatePreferences } = useConfig()
const { t } = useI18n()
const showUsagePreference = () => preferences().showUsageMetrics ?? true
const usageMetricsVisibility = () =>
preferences().showUsageMetrics ? preferences().usageMetricsExpansion : "hidden"
const showMessageTimelinePreference = () => preferences().showMessageTimeline ?? true
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? true
Expand Down Expand Up @@ -121,8 +122,8 @@ export default function MessageSection(props: MessageSectionProps) {
const pref = preferences()
const showThinking = pref.showThinkingBlocks ? 1 : 0
const thinkingExpansion = resolveThinkingExpansionDefault(pref) ? "expanded" : "collapsed"
const showUsage = (pref.showUsageMetrics ?? true) ? 1 : 0
return `${showThinking}|${thinkingExpansion}|${showUsage}`
const usageVisibility = pref.showUsageMetrics ? pref.usageMetricsExpansion : "hidden"
return `${showThinking}|${thinkingExpansion}|${usageVisibility}`
})

const handleTimelineSegmentClick = (segment: TimelineSegment) => {
Expand Down Expand Up @@ -1605,7 +1606,8 @@ export default function MessageSection(props: MessageSectionProps) {
lastAssistantIndex={lastAssistantIndex}
showThinking={() => preferences().showThinkingBlocks}
thinkingDefaultExpanded={() => resolveThinkingExpansionDefault(preferences())}
showUsageMetrics={showUsagePreference}
usageMetricsVisibility={usageMetricsVisibility}
toolVisibility={(toolName) => resolveToolVisibility(preferences(), toolName)}
deleteHover={deleteHover}
onDeleteHoverChange={setDeleteHover}
selectedMessageIds={selectedForDeletion}
Expand Down
Loading
Loading