share page diff

This commit is contained in:
Jay V 2025-05-30 13:58:32 -04:00
parent 680d52016c
commit a4e46e6e18
8 changed files with 413 additions and 72 deletions

View file

@ -36,9 +36,5 @@
"esbuild",
"protobufjs",
"sharp"
],
"dependencies": {
"@types/luxon": "^3.6.2",
"luxon": "^3.6.1"
}
]
}

View file

@ -14,10 +14,15 @@
"@astrojs/solid-js": "^5.1.0",
"@astrojs/starlight": "^0.34.3",
"@fontsource/ibm-plex-mono": "^5.2.5",
"@shikijs/transformers": "^3.4.2",
"@types/luxon": "^3.6.2",
"ai": "^5.0.0-alpha.2",
"astro": "^5.7.13",
"diff": "^8.0.2",
"luxon": "^3.6.1",
"rehype-autolink-headings": "^7.1.0",
"sharp": "^0.32.5",
"shiki": "^3.4.2",
"solid-js": "^1.9.7",
"toolbeam-docs-theme": "^0.2.4"
}

View file

@ -0,0 +1,47 @@
import {
type JSX,
onCleanup,
splitProps,
createEffect,
createResource,
} from "solid-js"
import { codeToHtml } from "shiki"
import { transformerNotationDiff } from '@shikijs/transformers'
interface CodeBlockProps extends JSX.HTMLAttributes<HTMLDivElement> {
code: string
lang?: string
}
function CodeBlock(props: CodeBlockProps) {
const [local, rest] = splitProps(props, ["code", "lang"])
let containerRef!: HTMLDivElement
const [html] = createResource(async () => {
return (await codeToHtml(local.code, {
lang: local.lang || "text",
themes: {
light: 'github-light',
dark: 'github-dark',
},
transformers: [
transformerNotationDiff(),
],
})) as string
})
onCleanup(() => {
if (containerRef) containerRef.innerHTML = ""
})
createEffect(() => {
if (html() && containerRef) {
containerRef.innerHTML = html() as string
}
})
return (
<div ref={containerRef} {...rest}></div>
)
}
export default CodeBlock

View file

@ -0,0 +1,66 @@
import { type Component, createSignal, onMount } from "solid-js"
import { diffLines, type Change } from "diff"
import CodeBlock from "./CodeBlock"
import styles from "./diffView.module.css"
type DiffRow = {
left: string
right: string
type: "added" | "removed" | "unchanged"
}
interface DiffViewProps {
oldCode: string
newCode: string
lang?: string
class?: string
}
const DiffView: Component<DiffViewProps> = (props) => {
const [rows, setRows] = createSignal<DiffRow[]>([])
onMount(() => {
const chunks = diffLines(props.oldCode, props.newCode)
const diffRows: DiffRow[] = []
chunks.forEach((chunk: Change) => {
const lines = chunk.value.split(/\r?\n/)
if (lines.at(-1) === "") lines.pop()
lines.forEach((line) => {
diffRows.push({
left: chunk.removed ? line : chunk.added ? "" : line,
right: chunk.added ? line : chunk.removed ? "" : line,
type: chunk.added ? "added"
: chunk.removed ? "removed"
: "unchanged",
})
})
})
setRows(diffRows)
})
return (
<div class={`${styles.diff} ${props.class ?? ""}`}>
{rows().map((r) => (
<div data-section="row">
<CodeBlock
code={r.left}
lang={props.lang}
data-section="cell"
data-diff-type={r.type === "removed" ? "removed" : ""}
/>
<CodeBlock
code={r.right}
lang={props.lang}
data-section="cell"
data-diff-type={r.type === "added" ? "added" : ""}
/>
</div>
))}
</div>
)
}
export default DiffView

View file

@ -6,6 +6,7 @@ import {
Switch,
onMount,
onCleanup,
splitProps,
createMemo,
createEffect,
createSignal,
@ -20,8 +21,13 @@ import {
IconCpuChip,
IconSparkles,
IconUserCircle,
IconChevronDown,
IconChevronRight,
IconPencilSquare,
IconWrenchScrewdriver,
} from "./icons"
import CodeBlock from "./CodeBlock"
import DiffView from "./DiffView"
import styles from "./share.module.css"
import { type UIMessage } from "ai"
import { createStore, reconcile } from "solid-js/store"
@ -59,6 +65,10 @@ type SessionInfo = {
cost?: number
}
function getFileType(path: string) {
return path.split('.').pop()
}
// Converts `{a:{b:{c:1}}` to `[['a.b.c', 1]]`
function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> {
const entries: Array<[string, any]> = [];
@ -111,18 +121,48 @@ function ProviderIcon(props: { provider: string, size?: number }) {
)
}
interface ResultsButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> {
results: boolean
}
function ResultsButton(props: ResultsButtonProps) {
const [local, rest] = splitProps(props, ["results"])
return (
<button
type="button"
data-element-button-text
data-element-button-more
{...rest}
>
<span>
{local.results ? "Hide results" : "Show results"}
</span>
<span data-button-icon>
<Show
when={local.results}
fallback={
<IconChevronRight width={10} height={10} />
}
>
<IconChevronDown width={10} height={10} />
</Show>
</span>
</button>
)
}
interface TextPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
text: string
expand?: boolean
highlight?: boolean
}
function TextPart({ text, expand, highlight, ...props }: TextPartProps) {
function TextPart(props: TextPartProps) {
const [local, rest] = splitProps(props, ["text", "expand", "highlight"])
const [expanded, setExpanded] = createSignal(false)
const [overflowed, setOverflowed] = createSignal(false)
let preEl: HTMLPreElement | undefined
function checkOverflow() {
if (preEl && !expand) {
if (preEl && !local.expand) {
setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1)
}
}
@ -133,7 +173,7 @@ function TextPart({ text, expand, highlight, ...props }: TextPartProps) {
})
createEffect(() => {
text
local.text
setTimeout(checkOverflow, 0)
})
@ -144,11 +184,11 @@ function TextPart({ text, expand, highlight, ...props }: TextPartProps) {
return (
<div
data-element-message-text
data-highlight={highlight}
data-expanded={expanded() || expand === true}
{...props}
data-highlight={local.highlight}
data-expanded={expanded() || local.expand === true}
{...rest}
>
<pre ref={el => (preEl = el)}>{text}</pre>
<pre ref={el => (preEl = el)}>{local.text}</pre>
{overflowed() &&
<button
type="button"
@ -411,6 +451,7 @@ export default function Share(props: { api: string }) {
{(part, partIndex) => {
if (part.type === "step-start" && (partIndex() > 0 || !msg.metadata?.assistant)) return null
const [results, showResults] = createSignal(false)
const isLastPart = createMemo(() =>
(messages().length === msgIndex() + 1)
&& (msg.parts.length === partIndex() + 1)
@ -488,16 +529,18 @@ export default function Share(props: { api: string }) {
<div></div>
</div>
<div data-section="content">
<span
data-size="md"
data-part-title
data-element-label
>
{assistant().providerID}
</span>
<span data-part-model>
{assistant().modelID}
</span>
<div data-part-tool-body>
<span
data-size="md"
data-part-title
data-element-label
>
{assistant().providerID}
</span>
<span data-part-model>
{assistant().modelID}
</span>
</div>
</div>
</>
}
@ -517,19 +560,59 @@ export default function Share(props: { api: string }) {
<div></div>
</div>
<div data-section="content">
<span data-element-label data-part-title>
System
</span>
<TextPart
data-size="sm"
text={part().text}
data-color="dimmed"
/>
<div data-part-tool-body>
<span data-element-label data-part-title>
System
</span>
<TextPart
data-size="sm"
text={part().text}
data-color="dimmed"
/>
</div>
<PartFooter time={time} />
</div>
</>
}
</Match>
{ /* Edit tool */}
<Match when={
msg.role === "assistant"
&& part.type === "tool-invocation"
&& part.toolInvocation.toolName === "edit"
&& part
}>
{part => {
const args = part().toolInvocation.args
const filePath = args.filePath
return (
<>
<div data-section="decoration">
<div>
<IconPencilSquare width={18} height={18} />
</div>
<div></div>
</div>
<div data-section="content">
<div data-part-tool-body>
<span data-part-title data-size="md">
Edit {filePath}
</span>
<div data-part-tool-edit>
<DiffView
class={styles["code-block"]}
oldCode={args.oldString}
newCode={args.newString}
lang={getFileType(filePath)}
/>
</div>
</div>
<PartFooter time={time} />
</div>
</>
)
}}
</Match>
{ /* Tool call */}
<Match when={
msg.role === "assistant"
@ -545,44 +628,54 @@ export default function Share(props: { api: string }) {
<div></div>
</div>
<div data-section="content">
<span data-part-title data-size="md">
{part().toolInvocation.toolName}
</span>
<div data-part-tool-args>
<For each={
flattenToolArgs(part().toolInvocation.args)
}>
{([name, value]) =>
<>
<div></div>
<div>{name}</div>
<div>{value}</div>
</>
}
</For>
<div data-part-tool-body>
<span data-part-title data-size="md">
{part().toolInvocation.toolName}
</span>
<div data-part-tool-args>
<For each={
flattenToolArgs(part().toolInvocation.args)
}>
{([name, value]) =>
<>
<div></div>
<div>{name}</div>
<div>{value}</div>
</>
}
</For>
</div>
<Switch>
<Match when={
part().toolInvocation.state === "result"
&& part().toolInvocation.result
}>
<div data-part-tool-result>
<ResultsButton
results={results()}
onClick={() => showResults(e => !e)}
/>
<Show when={results()}>
<TextPart
expand
data-size="sm"
data-color="dimmed"
text={part().toolInvocation.result}
/>
</Show>
</div>
</Match>
<Match when={
part().toolInvocation.state === "call"
}>
<TextPart
data-size="sm"
data-color="dimmed"
text="Calling..."
/>
</Match>
</Switch>
</div>
<Switch>
<Match when={
part().toolInvocation.state === "result"
&& part().toolInvocation.result
}>
<TextPart
data-size="sm"
data-color="dimmed"
text={part().toolInvocation.result}
expand={isLastPart()}
/>
</Match>
<Match when={
part().toolInvocation.state === "call"
}>
<TextPart
data-size="sm"
data-color="dimmed"
text="Calling..."
/>
</Match>
</Switch>
<PartFooter time={time} />
</div>
</>
@ -609,10 +702,12 @@ export default function Share(props: { api: string }) {
<div></div>
</div>
<div data-section="content">
<span data-element-label data-part-title>
{part.type}
</span>
<TextPart text={JSON.stringify(part, null, 2)} />
<div data-part-tool-body>
<span data-element-label data-part-title>
{part.type}
</span>
<TextPart text={JSON.stringify(part, null, 2)} />
</div>
<PartFooter time={time} />
</div>
</Match>

View file

@ -0,0 +1,70 @@
.diff {
display: grid;
row-gap: 0;
border: 1px solid var(--sl-color-divider);
background-color: var(--sl-color-bg-surface);
border-radius: 0.25rem;
[data-section="row"] {
display: grid;
grid-template-columns: 1fr 1fr;
&:first-child [data-section="cell"] {
padding-top: 0.375rem;
}
&:last-child [data-section="cell"] {
padding-bottom: 0.375rem;
}
}
[data-section="cell"] {
position: relative;
padding-left: 1.5ch;
padding: 0.25rem 0.5rem 0.25rem 1.5ch;
overflow-x: auto;
margin: 0;
pre {
background-color: var(--sl-color-bg-surface) !important;
}
&:first-child {
border-right: 1px solid var(--sl-color-divider);
}
}
[data-diff-type="removed"] {
background-color: var(--sl-color-red-low);
& > pre {
--shiki-dark-bg: var(--sl-color-red-low) !important;
background-color: transparent !important;
}
&::before {
content: "-";
position: absolute;
left: 0.5ch;
user-select: none;
color: var(--sl-color-red-high);
}
}
[data-diff-type="added"] {
background-color: var(--sl-color-green-low);
& > pre {
--shiki-dark-bg: var(--sl-color-green-low) !important;
background-color: transparent !important;
}
&::before {
content: "+";
position: absolute;
left: 0.6ch;
user-select: none;
color: var(--sl-color-green-high);
}
}
}

View file

@ -19,6 +19,33 @@
}
}
[data-element-button-text] {
cursor: pointer;
appearance: none;
background-color: transparent;
border: none;
padding: 0;
color: var(--sl-color-text-secondary);
&:hover {
color: var(--sl-color-text);
}
&[data-element-button-more] {
display: flex;
align-items: center;
gap: 0.125rem;
span[data-button-icon] {
line-height: 1;
opacity: 0.85;
svg {
display: block;
}
}
}
}
[data-element-label] {
text-transform: uppercase;
letter-spacing: 0.05em;
@ -154,7 +181,13 @@
padding: 0 0 0.375rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 1rem;
[data-part-tool-body] {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
span[data-part-title] {
line-height: 18px;
@ -203,7 +236,17 @@
padding-left: 0.125rem;
color: var(--sl-color-text-dimmed);
}
}
[data-part-tool-result] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
button {
font-size: 0.75rem;
}
}
}
}
@ -274,3 +317,10 @@
}
}
}
.code-block {
pre {
line-height: 1.4;
font-size: 0.75rem;
}
}

View file

@ -2,3 +2,15 @@
--sl-color-bg-surface: var(--sl-color-bg-nav);
--sl-color-divider: var(--sl-color-gray-5);
}
@media (prefers-color-scheme: dark) {
.shiki,
.shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
/* Optional, if you also want font styles */
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
}