summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-10-24 08:26:17 -0500
committerAdam <[email protected]>2025-10-24 12:16:33 -0500
commitfe8f6d7a3eef34e932bd43d244460d417865de88 (patch)
tree3129fd2072fd3fd0dd842e33c59e0f1f256c1df3
parent59b5f5350950e9cac81676f45eaca8611514c4d1 (diff)
downloadopencode-fe8f6d7a3eef34e932bd43d244460d417865de88.tar.gz
opencode-fe8f6d7a3eef34e932bd43d244460d417865de88.zip
wip: desktop work
-rw-r--r--packages/desktop/src/components/diff.tsx140
-rw-r--r--packages/desktop/src/context/local.tsx6
-rw-r--r--packages/desktop/src/pages/index.tsx155
-rw-r--r--packages/opencode/test/fixture/fixture.ts3
-rw-r--r--packages/ui/src/components/collapsible.css46
-rw-r--r--packages/ui/src/components/collapsible.tsx35
-rw-r--r--packages/ui/src/components/index.ts1
-rw-r--r--packages/ui/src/demo.tsx36
8 files changed, 350 insertions, 72 deletions
diff --git a/packages/desktop/src/components/diff.tsx b/packages/desktop/src/components/diff.tsx
new file mode 100644
index 000000000..4667bbb3a
--- /dev/null
+++ b/packages/desktop/src/components/diff.tsx
@@ -0,0 +1,140 @@
+import { type FileContents, FileDiff, type DiffLineAnnotation } from "@pierre/precision-diffs"
+
+export interface DiffProps {
+ before: FileContents
+ after: FileContents
+}
+
+export function Diff(props: DiffProps) {
+ let container!: HTMLDivElement
+
+ console.log(props)
+
+ interface ThreadMetadata {
+ threadId: string
+ }
+
+ const lineAnnotations: DiffLineAnnotation<ThreadMetadata>[] = [
+ {
+ side: "additions",
+ // The line number specified for an annotation is the visual line number
+ // you see in the number column of a diff
+ lineNumber: 16,
+ metadata: { threadId: "68b329da9893e34099c7d8ad5cb9c940" },
+ },
+ ]
+
+ const instance = new FileDiff<ThreadMetadata>({
+ // You can provide a 'theme' prop that maps to any
+ // built in shiki theme or you can register a custom
+ // theme. We also include 2 custom themes
+ //
+ // 'pierre-night' and 'pierre-light
+ //
+ // For the rest of the available shiki themes, check out:
+ // https://shiki.style/themes
+ theme: "none",
+ // Or can also provide a 'themes' prop, which allows the code to adapt
+ // to your OS light or dark theme
+ // themes: { dark: 'pierre-night', light: 'pierre-light' },
+ // When using the 'themes' prop, 'themeType' allows you to force 'dark'
+ // or 'light' theme, or inherit from the OS ('system') theme.
+ themeType: "system",
+ // Disable the line numbers for your diffs, generally not recommended
+ disableLineNumbers: false,
+ // Whether code should 'wrap' with long lines or 'scroll'.
+ overflow: "scroll",
+ // Normally you shouldn't need this prop, but if you don't provide a
+ // valid filename or your file doesn't have an extension you may want to
+ // override the automatic detection. You can specify that language here:
+ // https://shiki.style/languages
+ // lang?: SupportedLanguages;
+ // 'diffStyle' controls whether the diff is presented side by side or
+ // in a unified (single column) view
+ diffStyle: "split",
+ // Line decorators to help highlight changes.
+ // 'bars' (default):
+ // Shows some red-ish or green-ish (theme dependent) bars on the left
+ // edge of relevant lines
+ //
+ // 'classic':
+ // shows '+' characters on additions and '-' characters on deletions
+ //
+ // 'none':
+ // No special diff indicators are shown
+ diffIndicators: "bars",
+ // By default green-ish or red-ish background are shown on added and
+ // deleted lines respectively. Disable that feature here
+ disableBackground: false,
+ // Diffs are split up into hunks, this setting customizes what to show
+ // between each hunk.
+ //
+ // 'line-info' (default):
+ // Shows a bar that tells you how many lines are collapsed. If you are
+ // using the oldFile/newFile API then you can click those bars to
+ // expand the content between them
+ //
+ // 'metadata':
+ // Shows the content you'd see in a normal patch file, usually in some
+ // format like '@@ -60,6 +60,22 @@'. You cannot use these to expand
+ // hidden content
+ //
+ // 'simple':
+ // Just a subtle bar separator between each hunk
+ hunkSeparators: "line-info",
+ // On lines that have both additions and deletions, we can run a
+ // separate diff check to mark parts of the lines that change.
+ // 'none':
+ // Do not show these secondary highlights
+ //
+ // 'char':
+ // Show changes at a per character granularity
+ //
+ // 'word':
+ // Show changes but rounded up to word boundaries
+ //
+ // 'word-alt' (default):
+ // Similar to 'word', however we attempt to minimize single character
+ // gaps between highlighted changes
+ lineDiffType: "word-alt",
+ // If lines exceed these character lengths then we won't perform the
+ // line lineDiffType check
+ maxLineDiffLength: 1000,
+ // If any line in the diff exceeds this value then we won't attempt to
+ // syntax highlight the diff
+ maxLineLengthForHighlighting: 1000,
+ // Enabling this property will hide the file header with file name and
+ // diff stats.
+ disableFileHeader: false,
+ // You can optionally pass a render function for rendering out line
+ // annotations. Just return the dom node to render
+ renderAnnotation(annotation: DiffLineAnnotation<ThreadMetadata>): HTMLElement {
+ // Despite the diff itself being rendered in the shadow dom,
+ // annotations are inserted via the web components 'slots' api and you
+ // can use all your normal normal css and styling for them
+ const element = document.createElement("div")
+ element.innerText = annotation.metadata.threadId
+ return element
+ },
+ })
+
+ // If you ever want to update the options for an instance, simple call
+ // 'setOptions' with the new options. Bear in mind, this does NOT merge
+ // existing properties, it's a full replace
+ instance.setOptions({
+ ...instance.options,
+ theme: "pierre-dark",
+ themes: undefined,
+ })
+
+ // When ready to render, simply call .render with old/new file, optional
+ // annotations and a container element to hold the diff
+ instance.render({
+ oldFile: props.before,
+ newFile: props.after,
+ lineAnnotations,
+ containerWrapper: container,
+ })
+
+ return <div ref={container} />
+}
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index 981039bb6..6ed8ec17b 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/packages/desktop/src/context/local.tsx
@@ -467,11 +467,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
})
- const activeAssistantMessagesWithText = createMemo(() => {
- if (!store.active || !activeAssistantMessages()) return []
- return activeAssistantMessages()?.filter((m) => sync.data.part[m.id].find((p) => p.type === "text"))
- })
-
const model = createMemo(() => {
if (!last()) return
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
@@ -510,7 +505,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
active,
activeMessage,
activeAssistantMessages,
- activeAssistantMessagesWithText,
lastUserMessage,
cost,
last,
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
index 1542853ea..f6ea4cb90 100644
--- a/packages/desktop/src/pages/index.tsx
+++ b/packages/desktop/src/pages/index.tsx
@@ -1,7 +1,7 @@
import { Button, List, SelectDialog, Tooltip, IconButton, Tabs, Icon } from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree"
-import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect } from "solid-js"
+import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
import { useLocal, type LocalFile, type TextSelection } from "@/context/local"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
@@ -21,6 +21,7 @@ import type { JSX } from "solid-js"
import { Code } from "@/components/code"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
+import { Diff } from "@/components/diff"
export default function Page() {
const local = useLocal()
@@ -374,27 +375,36 @@ export default function Page() {
onSelect={(s) => local.session.setActive(s?.id)}
onHover={(s) => (!!s ? sync.session.sync(s?.id) : undefined)}
>
- {(session) => (
- <Tooltip placement="right" value={session.title}>
- <div>
- <div class="flex items-center self-stretch gap-6">
- <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
- {session.title}
- </span>
- <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
- {DateTime.fromMillis(session.time.updated).toRelative()}
- </span>
- </div>
- <div class="flex justify-between items-center self-stretch">
- <span class="text-12-regular text-text-weak">2 files changed</span>
- <div class="flex gap-2 justify-end items-center">
- <span class="text-12-mono text-right text-text-diff-add-base">+43</span>
- <span class="text-12-mono text-right text-text-diff-delete-base">-2</span>
+ {(session) => {
+ const diffs = createMemo(() => session.summary?.diffs ?? [])
+ const filesChanged = createMemo(() => diffs().length)
+ const additions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.additions ?? 0), 0))
+ const deletions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.deletions ?? 0), 0))
+
+ return (
+ <Tooltip placement="right" value={session.title}>
+ <div>
+ <div class="flex items-center self-stretch gap-6">
+ <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
+ {session.title}
+ </span>
+ <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
+ {DateTime.fromMillis(session.time.updated).toRelative()}
+ </span>
+ </div>
+ <div class="flex justify-between items-center self-stretch">
+ <span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span>
+ <Show when={additions() || deletions()}>
+ <div class="flex gap-2 justify-end items-center">
+ <span class="text-12-mono text-right text-text-diff-add-base">{`+${additions()}`}</span>
+ <span class="text-12-mono text-right text-text-diff-delete-base">{`-${deletions()}`}</span>
+ </div>
+ </Show>
</div>
</div>
- </div>
- </Tooltip>
- )}
+ </Tooltip>
+ )
+ }}
</List>
</div>
</div>
@@ -521,60 +531,77 @@ export default function Page() {
{(activeSession) => (
<div class="py-3 flex flex-col flex-1 min-h-0">
<div class="flex items-start gap-8 flex-1 min-h-0">
- <ul role="list" class="w-60 shrink-0 flex flex-col items-start gap-1">
- <For each={local.session.userMessages()}>
- {(message) => (
- <li
- class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
- onClick={() => local.session.setActiveMessage(message.id)}
- >
- <div class="w-[18px] shrink-0">
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
- <g>
- <rect x="0" width="2" height="12" rx="1" fill="#CFCECD" />
- <rect x="4" width="2" height="12" rx="1" fill="#CFCECD" />
- <rect x="8" width="2" height="12" rx="1" fill="#CFCECD" />
- <rect x="12" width="2" height="12" rx="1" fill="#CFCECD" />
- <rect x="16" width="2" height="12" rx="1" fill="#CFCECD" />
- </g>
- </svg>
- </div>
- <div
- data-active={local.session.activeMessage()?.id === message.id}
- classList={{
- "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
- "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
- }}
+ <Show when={local.session.userMessages().length > 1}>
+ <ul role="list" class="w-60 shrink-0 flex flex-col items-start gap-1">
+ <For each={local.session.userMessages()}>
+ {(message) => (
+ <li
+ class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
+ onClick={() => local.session.setActiveMessage(message.id)}
>
- {local.session.getMessageText(message)}
- </div>
- </li>
- )}
- </For>
- </ul>
+ <div class="w-[18px] shrink-0">
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
+ <g>
+ <rect x="0" width="2" height="12" rx="1" fill="#CFCECD" />
+ <rect x="4" width="2" height="12" rx="1" fill="#CFCECD" />
+ <rect x="8" width="2" height="12" rx="1" fill="#CFCECD" />
+ <rect x="12" width="2" height="12" rx="1" fill="#CFCECD" />
+ <rect x="16" width="2" height="12" rx="1" fill="#CFCECD" />
+ </g>
+ </svg>
+ </div>
+ <div
+ data-active={local.session.activeMessage()?.id === message.id}
+ classList={{
+ "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
+ "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
+ }}
+ >
+ {local.session.getMessageText(message)}
+ </div>
+ </li>
+ )}
+ </For>
+ </ul>
+ </Show>
<div
ref={messageScrollElement}
class="grow min-w-0 h-full overflow-y-auto no-scrollbar snap-y"
>
<div class="flex flex-col items-start gap-50 pb-[800px]">
<For each={local.session.userMessages()}>
- {(message) => (
- <div
- data-message={message.id}
- class="flex flex-col items-start self-stretch gap-8 pt-1.5 snap-start"
- >
- <div class="flex flex-col items-start gap-4">
- <div class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
- {local.session.getMessageText(message)}
+ {(message) => {
+ console.log(message)
+ return (
+ <div
+ data-message={message.id}
+ class="flex flex-col items-start self-stretch gap-8 pt-1.5 snap-start"
+ >
+ <div class="flex flex-col items-start gap-4">
+ <div class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
+ {local.session.getMessageText(message)}
+ </div>
+ <div class="text-14-regular text-text-base">{message.summary?.text}</div>
</div>
- <div class="text-14-regular text-text-base">
- {message.summary?.text ||
- local.session.getMessageText(local.session.activeAssistantMessagesWithText())}
+ <div class="">
+ <For each={message.summary?.diffs}>
+ {(diff) => (
+ <Diff
+ before={{
+ name: diff.file!,
+ contents: diff.before!,
+ }}
+ after={{
+ name: diff.file!,
+ contents: diff.after!,
+ }}
+ />
+ )}
+ </For>
</div>
</div>
- <div class=""></div>
- </div>
- )}
+ )
+ }}
</For>
</div>
</div>
diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts
index 0b83bb316..0d3e0c917 100644
--- a/packages/opencode/test/fixture/fixture.ts
+++ b/packages/opencode/test/fixture/fixture.ts
@@ -1,4 +1,5 @@
import { $ } from "bun"
+import { realpathSync } from "fs"
import os from "os"
import path from "path"
@@ -17,7 +18,7 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
await options?.dispose?.(dirpath)
await $`rm -rf ${dirpath}`.quiet()
},
- path: dirpath,
+ path: realpathSync(dirpath),
extra: extra as T,
}
return result
diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css
new file mode 100644
index 000000000..441d0083f
--- /dev/null
+++ b/packages/ui/src/components/collapsible.css
@@ -0,0 +1,46 @@
+[data-component="collapsible"] {
+ display: flex;
+ flex-direction: column;
+
+ [data-slot="trigger"] {
+ cursor: pointer;
+ user-select: none;
+
+ &:focus-visible {
+ outline: 2px solid var(--border-focus);
+ outline-offset: 2px;
+ }
+
+ &[data-disabled] {
+ cursor: not-allowed;
+ opacity: 0.5;
+ }
+ }
+
+ [data-slot="content"] {
+ overflow: hidden;
+ /* animation: slideUp 250ms ease-out; */
+
+ /* &[data-expanded] { */
+ /* animation: slideDown 250ms ease-out; */
+ /* } */
+ }
+}
+
+@keyframes slideDown {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--kb-collapsible-content-height);
+ }
+}
+
+@keyframes slideUp {
+ from {
+ height: var(--kb-collapsible-content-height);
+ }
+ to {
+ height: 0;
+ }
+}
diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx
new file mode 100644
index 000000000..011d103c1
--- /dev/null
+++ b/packages/ui/src/components/collapsible.tsx
@@ -0,0 +1,35 @@
+import { Collapsible as Kobalte, CollapsibleRootProps } from "@kobalte/core/collapsible"
+import { ComponentProps, ParentProps, splitProps } from "solid-js"
+
+export interface CollapsibleProps extends ParentProps<CollapsibleRootProps> {
+ class?: string
+ classList?: ComponentProps<"div">["classList"]
+}
+
+function CollapsibleRoot(props: CollapsibleProps) {
+ const [local, others] = splitProps(props, ["class", "classList"])
+
+ return (
+ <Kobalte
+ data-component="collapsible"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ {...others}
+ />
+ )
+}
+
+function CollapsibleTrigger(props: ComponentProps<typeof Kobalte.Trigger>) {
+ return <Kobalte.Trigger data-slot="trigger" {...props} />
+}
+
+function CollapsibleContent(props: ComponentProps<typeof Kobalte.Content>) {
+ return <Kobalte.Content data-slot="content" {...props} />
+}
+
+export const Collapsible = Object.assign(CollapsibleRoot, {
+ Trigger: CollapsibleTrigger,
+ Content: CollapsibleContent,
+})
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index 71cfd3a89..3691363cd 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -1,4 +1,5 @@
export * from "./button"
+export * from "./collapsible"
export * from "./dialog"
export * from "./icon"
export * from "./icon-button"
diff --git a/packages/ui/src/demo.tsx b/packages/ui/src/demo.tsx
index 85fd20c0e..7c507d7d9 100644
--- a/packages/ui/src/demo.tsx
+++ b/packages/ui/src/demo.tsx
@@ -1,6 +1,19 @@
import type { Component } from "solid-js"
import { createSignal } from "solid-js"
-import { Button, Select, Tabs, Tooltip, Fonts, List, Dialog, Icon, IconButton, Input, SelectDialog } from "./components"
+import {
+ Button,
+ Select,
+ Tabs,
+ Tooltip,
+ Fonts,
+ List,
+ Dialog,
+ Icon,
+ IconButton,
+ Input,
+ SelectDialog,
+ Collapsible,
+} from "./components"
import "./index.css"
const Demo: Component = () => {
@@ -180,6 +193,27 @@ const Demo: Component = () => {
{(item) => <div>{item}</div>}
</SelectDialog>
</section>
+ <h3>Collapsible</h3>
+ <section>
+ <Collapsible>
+ <Collapsible.Trigger>
+ <Button variant="secondary">Toggle Content</Button>
+ </Collapsible.Trigger>
+ <Collapsible.Content>
+ <div
+ style={{
+ padding: "16px",
+ "background-color": "var(--surface-base)",
+ "border-radius": "8px",
+ "margin-top": "8px",
+ }}
+ >
+ <p>This is collapsible content that can be toggled open and closed.</p>
+ <p>It animates smoothly using CSS animations.</p>
+ </div>
+ </Collapsible.Content>
+ </Collapsible>
+ </section>
</div>
)