Browse Source

share page diff

Jay V 9 months ago
parent
commit
a4e46e6e18

+ 1 - 5
app/package.json

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

+ 5 - 0
app/packages/web/package.json

@@ -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"
   }

+ 47 - 0
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

+ 66 - 0
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

+ 161 - 66
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>

+ 70 - 0
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);
+    }
+  }
+}
+

+ 51 - 1
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;
+  }
+}

+ 12 - 0
app/packages/web/src/styles/custom.css

@@ -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;
+  }
+}