wip: desktop work

This commit is contained in:
Adam
2025-10-24 08:26:17 -05:00
parent 59b5f53509
commit fe8f6d7a3e
8 changed files with 350 additions and 72 deletions

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -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,
})

View File

@@ -1,4 +1,5 @@
export * from "./button"
export * from "./collapsible"
export * from "./dialog"
export * from "./icon"
export * from "./icon-button"

View File

@@ -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>
)