summaryrefslogtreecommitdiffhomepage
path: root/app/packages/web/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/packages/web/src/components')
-rw-r--r--app/packages/web/src/components/CodeBlock.tsx47
-rw-r--r--app/packages/web/src/components/DiffView.tsx66
-rw-r--r--app/packages/web/src/components/Share.tsx227
-rw-r--r--app/packages/web/src/components/diffview.module.css70
-rw-r--r--app/packages/web/src/components/share.module.css52
5 files changed, 395 insertions, 67 deletions
diff --git a/app/packages/web/src/components/CodeBlock.tsx b/app/packages/web/src/components/CodeBlock.tsx
new file mode 100644
index 000000000..17559ece1
--- /dev/null
+++ b/app/packages/web/src/components/CodeBlock.tsx
@@ -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
diff --git a/app/packages/web/src/components/DiffView.tsx b/app/packages/web/src/components/DiffView.tsx
new file mode 100644
index 000000000..443fc6f47
--- /dev/null
+++ b/app/packages/web/src/components/DiffView.tsx
@@ -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
diff --git a/app/packages/web/src/components/Share.tsx b/app/packages/web/src/components/Share.tsx
index 12619b600..c9fbb0680 100644
--- a/app/packages/web/src/components/Share.tsx
+++ b/app/packages/web/src/components/Share.tsx
@@ -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>
diff --git a/app/packages/web/src/components/diffview.module.css b/app/packages/web/src/components/diffview.module.css
new file mode 100644
index 000000000..94911d06f
--- /dev/null
+++ b/app/packages/web/src/components/diffview.module.css
@@ -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);
+ }
+ }
+}
+
diff --git a/app/packages/web/src/components/share.module.css b/app/packages/web/src/components/share.module.css
index 1c0681623..e2ebccab2 100644
--- a/app/packages/web/src/components/share.module.css
+++ b/app/packages/web/src/components/share.module.css
@@ -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;
+ }
+}