message dialog

This commit is contained in:
Dax Raad 2025-09-28 05:01:00 -04:00
parent 5d57607801
commit 256dead643
3 changed files with 153 additions and 68 deletions

View file

@ -0,0 +1,40 @@
import { createMemo } from "solid-js"
import { useSync } from "../../context/sync"
import { DialogSelect } from "../../ui/dialog-select"
import { useSDK } from "../../context/sdk"
export function DialogMessage(props: { messageID: string; sessionID: string }) {
const sync = useSync()
const sdk = useSDK()
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
return (
<DialogSelect
title="Message"
options={[
{
title: "Revert",
value: "session.revert",
description: "rollback everything after this message",
onSelect: (dialog) => {
sdk.session.revert({
path: {
id: props.sessionID,
},
body: {
messageID: message()!.id,
},
})
dialog.clear()
},
},
{
title: "Fork",
value: "session.fork",
description: "create a new session",
onSelect: () => {},
},
]}
/>
)
}

View file

@ -1,4 +1,4 @@
import { createEffect, createMemo, For, Match, Show, Switch, type Component } from "solid-js"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type Component } from "solid-js"
import { Dynamic } from "solid-js/web"
import path from "path"
import { useRouteData } from "@tui/context/route"
@ -29,6 +29,8 @@ import { Shimmer } from "@tui/ui/shimmer"
import { useKeybind } from "@tui/context/keybind"
import { Header } from "./header"
import { parsePatch } from "diff"
import { useDialog } from "../../ui/dialog"
import { DialogMessage } from "./dialog-message"
export function Session() {
const route = useRouteData("session")
@ -162,38 +164,44 @@ export function Session() {
},
])
const revert = createMemo(() => {
const s = session()
if (!s) return
const messageID = s.revert?.messageID
if (!messageID) return
const reverted = messages().filter((x) => x.id >= messageID && x.role === "user")
const revert = createMemo(() => {
const s = session()
if (!s) return
const messageID = s.revert?.messageID
if (!messageID) return
const reverted = messages().filter((x) => x.id >= messageID && x.role === "user")
const diffFiles = (() => {
const diffText = s.revert?.diff || ""
if (!diffText) return []
const patches = parsePatch(diffText)
return patches.map(patch => {
const filename = patch.newFileName || patch.oldFileName || 'unknown'
const cleanFilename = filename.replace(/^[ab]\//, '')
return {
filename: cleanFilename,
additions: patch.hunks.reduce((sum, hunk) =>
sum + hunk.lines.filter(line => line.startsWith('+')).length, 0),
deletions: patch.hunks.reduce((sum, hunk) =>
sum + hunk.lines.filter(line => line.startsWith('-')).length, 0)
}
})
})()
const diffFiles = (() => {
const diffText = s.revert?.diff || ""
if (!diffText) return []
return {
messageID,
reverted,
diff: s.revert!.diff,
diffFiles,
}
})
const patches = parsePatch(diffText)
return patches.map((patch) => {
const filename = patch.newFileName || patch.oldFileName || "unknown"
const cleanFilename = filename.replace(/^[ab]\//, "")
return {
filename: cleanFilename,
additions: patch.hunks.reduce(
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
0,
),
deletions: patch.hunks.reduce(
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
0,
),
}
})
})()
return {
messageID,
reverted,
diff: s.revert!.diff,
diffFiles,
}
})
const dialog = useDialog()
return (
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexGrow={1}>
@ -224,23 +232,23 @@ export function Session() {
<text fg={Theme.textMuted}>
<span style={{ fg: Theme.text }}>{keybind.print("messages_redo")}</span> or /redo to restore
</text>
<Show when={revert()!.diffFiles?.length}>
<box marginTop={1}>
<For each={revert()!.diffFiles}>
{(file) => (
<text>
{file.filename}
<Show when={file.additions > 0}>
<span style={{ fg: Theme.diffAdded }}> +{file.additions}</span>
</Show>
<Show when={file.deletions > 0}>
<span style={{ fg: Theme.diffRemoved }}> -{file.deletions}</span>
</Show>
</text>
)}
</For>
</box>
</Show>
<Show when={revert()!.diffFiles?.length}>
<box marginTop={1}>
<For each={revert()!.diffFiles}>
{(file) => (
<text>
{file.filename}
<Show when={file.additions > 0}>
<span style={{ fg: Theme.diffAdded }}> +{file.additions}</span>
</Show>
<Show when={file.deletions > 0}>
<span style={{ fg: Theme.diffRemoved }}> -{file.deletions}</span>
</Show>
</text>
)}
</For>
</box>
</Show>
</box>
</box>
</Match>
@ -248,7 +256,13 @@ export function Session() {
<></>
</Match>
<Match when={message.role === "user"}>
<UserMessage message={message as UserMessage} parts={sync.data.part[message.id] ?? []} />
<UserMessage
onMouseDown={() =>
dialog.replace(() => <DialogMessage messageID={message.id} sessionID={route.sessionID} />)
}
message={message as UserMessage}
parts={sync.data.part[message.id] ?? []}
/>
</Match>
<Match when={message.role === "assistant"}>
<AssistantMessage
@ -294,18 +308,27 @@ const MIME_BADGE: Record<string, string> = {
"application/pdf": "pdf",
}
function UserMessage(props: { message: UserMessage; parts: Part[] }) {
function UserMessage(props: { message: UserMessage; parts: Part[]; onMouseDown: () => void }) {
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const sync = useSync()
const [hover, setHover] = createSignal(false)
return (
<box
onMouseOver={() => {
setHover(true)
}}
onMouseOut={() => {
setHover(false)
}}
onMouseDown={props.onMouseDown}
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
marginTop={1}
backgroundColor={Theme.backgroundPanel}
backgroundColor={hover() ? Theme.backgroundElement : Theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={Theme.secondary}
flexShrink={0}

View file

@ -39,13 +39,20 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let input: InputRenderable
const grouped = createMemo(() => {
const filtered = createMemo(() => {
const needle = store.filter.toLowerCase()
const result = pipe(
props.options,
filter((x) => x.disabled !== false),
take(props.limit ?? Infinity),
(x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
)
return result
})
const grouped = createMemo(() => {
const result = pipe(
filtered(),
groupBy((x) => x.category ?? ""),
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
entries(),
@ -71,6 +78,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let next = store.selected + direction
if (next < 0) next = flat().length - 1
if (next >= flat().length) next = 0
moveTo(next)
}
function moveTo(next: number) {
setStore("selected", next)
const target = scroll.getChildren().find((child) => {
return child.id === JSON.stringify(selected()?.value)
@ -148,15 +159,32 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</Show>
<For each={options}>
{(option) => {
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
return (
<Option
<box
id={JSON.stringify(option.value)}
title={option.title}
footer={option.footer ?? (option.keybind ? keybind.print(option.keybind as any) : undefined)}
description={option.description !== category ? option.description : undefined}
active={isDeepEqual(option.value, selected()?.value)}
current={isDeepEqual(option.value, props.current)}
/>
flexDirection="row"
onMouseUp={() => {
option.onSelect?.(dialog)
props.onSelect?.(option)
}}
onMouseOver={() => {
const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
}}
backgroundColor={active() ? Theme.primary : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={1}
paddingRight={1}
>
<Option
title={option.title}
footer={option.footer ?? (option.keybind ? keybind.print(option.keybind as any) : undefined)}
description={option.description !== category ? option.description : undefined}
active={active()}
current={isDeepEqual(option.value, props.current)}
/>
</box>
)
}}
</For>
@ -181,21 +209,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
function Option(props: {
id: string
title: string
description?: string
active?: boolean
current?: boolean
footer?: string
onMouseOver?: () => void
}) {
return (
<box
id={props.id}
flexDirection="row"
backgroundColor={props.active ? Theme.primary : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={1}
paddingRight={1}
>
<>
<box flexGrow={1} flexShrink={0} flexDirection="row">
<text
fg={props.active ? Theme.background : props.current ? Theme.primary : Theme.text}
@ -208,6 +230,6 @@ function Option(props: {
<Show when={props.footer}>
<text fg={props.active ? Theme.background : Theme.textMuted}>{props.footer}</text>
</Show>
</box>
</>
)
}