wip: desktop work

This commit is contained in:
Adam
2025-10-31 11:54:27 -05:00
parent 36b48a44ac
commit ffc889b99e
9 changed files with 174 additions and 175 deletions

View File

@@ -0,0 +1,82 @@
import { For, Match, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { Part } from "@opencode-ai/ui"
import { useSync } from "@/context/sync"
import type { AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[] }) {
const sync = useSync()
const items = createMemo(() => props.assistantMessages().flatMap((m) => sync.data.part[m.id]))
const finishedItems = createMemo(() => [
"",
"",
"Loading...",
...items().filter(
(p) =>
p?.type === "text" ||
(p?.type === "reasoning" && p.time?.end) ||
(p?.type === "tool" && p.state.status === "completed"),
),
"",
])
const MINIMUM_DELAY = 400
const [visibleCount, setVisibleCount] = createSignal(1)
createEffect(() => {
const total = finishedItems().length
if (total > visibleCount()) {
const timer = setTimeout(() => {
setVisibleCount((prev) => prev + 1)
}, MINIMUM_DELAY)
onCleanup(() => clearTimeout(timer))
} else if (total < visibleCount()) {
setVisibleCount(total)
}
})
const translateY = createMemo(() => {
const total = visibleCount()
if (total < 2) return "0px"
return `-${(total - 2) * 40 - 8}px`
})
return (
<div
class="h-30 overflow-hidden pointer-events-none
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
mask-b-from-90% mask-b-from-background-base mask-b-to-transparent"
>
<div
class="w-full flex flex-col items-start self-stretch gap-2 py-8
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ transform: `translateY(${translateY()})` }}
>
<For each={finishedItems()}>
{(part) => {
if (typeof part === "string") return <div class="h-8 flex items-center w-full">{part}</div>
const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID))
return (
<div class="h-8 flex items-center w-full">
<Switch>
<Match when={part.type === "text" && part}>
{(p) => (
<div
textContent={p().text}
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
/>
)}
</Match>
<Match when={part.type === "reasoning" && part}>
{(p) => <Part message={message()!} part={p()} />}
</Match>
<Match when={part.type === "tool" && part}>{(p) => <Part message={message()!} part={p()} />}</Match>
</Switch>
</div>
)
}}
</For>
</div>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { ComponentProps, For } from "solid-js"
export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) {
const squares = Array.from({ length: 16 }, (_, i) => ({
id: i,
x: (i % 4) * 4,
y: Math.floor(i / 4) * 4,
delay: Math.random() * 3,
duration: 2 + Math.random() * 2,
}))
return (
<svg
viewBox="0 0 15 15"
classList={{
"size-4": true,
...(props.classList ?? {}),
[props.class ?? ""]: !!props.class,
}}
fill="currentColor"
>
<For each={squares}>
{(square) => (
<rect
x={square.x}
y={square.y}
width="3"
height="3"
rx="1"
style={{
animation: `pulse-opacity ${square.duration}s ease-in-out infinite`,
"animation-delay": `${square.delay}s`,
}}
/>
)}
</For>
</svg>
)
}

View File

@@ -17,6 +17,7 @@ import {
} from "@opencode-ai/ui" } from "@opencode-ai/ui"
import { FileIcon } from "@/ui" import { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree" import FileTree from "@/components/file-tree"
import { MessageProgress } from "@/components/message-progress"
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js" import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
import { useLocal, type LocalFile } from "@/context/local" import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@@ -39,6 +40,7 @@ import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
import { Markdown } from "@opencode-ai/ui" import { Markdown } from "@opencode-ai/ui"
import { Spinner } from "@/components/spinner"
export default function Page() { export default function Page() {
const local = useLocal() const local = useLocal()
@@ -546,21 +548,31 @@ export default function Page() {
<For each={local.session.userMessages()}> <For each={local.session.userMessages()}>
{(message) => { {(message) => {
const diffs = createMemo(() => message.summary?.diffs ?? []) const diffs = createMemo(() => message.summary?.diffs ?? [])
const working = createMemo(() => !message.summary?.title)
return ( return (
<li <li class="group/li flex items-center self-stretch">
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default" <button
onClick={() => local.session.setActiveMessage(message.id)} class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
> onClick={() => local.session.setActiveMessage(message.id)}
<DiffChanges diff={diffs()} variant="bars" />
<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,
}}
> >
{message.summary?.title ?? local.session.getMessageText(message)} <Switch>
</div> <Match when={working()}>
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
</Match>
<Match when={true}>
<DiffChanges diff={diffs()} variant="bars" />
</Match>
</Switch>
<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,
}}
>
{message.summary?.title ?? local.session.getMessageText(message)}
</div>
</button>
</li> </li>
) )
}} }}
@@ -600,19 +612,15 @@ export default function Page() {
</Show> </Show>
</div> </div>
</div> </div>
<Show when={title}> <div class="-mt-8">
<div class="-mt-8"> <Message message={message} parts={parts()} />
<Message message={message} parts={parts()} /> </div>
</div>
</Show>
{/* Summary */} {/* Summary */}
<Show when={!working()}> <Show when={!working()}>
<div class="w-full flex flex-col gap-6 items-start self-stretch"> <div class="w-full flex flex-col gap-6 items-start self-stretch">
<div class="flex flex-col items-start gap-1 self-stretch"> <div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">Summary</h2> <h2 class="text-12-medium text-text-weak">Summary</h2>
<Show when={summary()}> <Show when={summary()}>{(summary) => <Markdown text={summary()} />}</Show>
<Markdown text={summary()!} />
</Show>
</div> </div>
<Accordion class="w-full" multiple> <Accordion class="w-full" multiple>
<For each={message.summary?.diffs || []}> <For each={message.summary?.diffs || []}>
@@ -666,85 +674,7 @@ export default function Page() {
<div class="w-full"> <div class="w-full">
<Switch> <Switch>
<Match when={working()}> <Match when={working()}>
{(_) => { <MessageProgress assistantMessages={assistantMessages} />
const items = createMemo(() =>
assistantMessages().flatMap((m) => sync.data.part[m.id]),
)
const finishedItems = createMemo(() =>
items().filter(
(p) =>
(p?.type === "text" && p.time?.end) ||
(p?.type === "reasoning" && p.time?.end) ||
(p?.type === "tool" && p.state.status === "completed"),
),
)
const MINIMUM_DELAY = 800
const [visibleCount, setVisibleCount] = createSignal(1)
createEffect(() => {
const total = finishedItems().length
if (total > visibleCount()) {
const timer = setTimeout(() => {
setVisibleCount((prev) => prev + 1)
}, MINIMUM_DELAY)
onCleanup(() => clearTimeout(timer))
} else if (total < visibleCount()) {
setVisibleCount(total)
}
})
const translateY = createMemo(() => {
const total = visibleCount()
if (total < 2) return "0px"
return `-${(total - 2) * 48 - 8}px`
})
return (
<div class="flex flex-col gap-3">
<div
class="h-36 overflow-hidden pointer-events-none
mask-alpha mask-y-from-66% mask-y-from-background-base mask-y-to-transparent"
>
<div
class="w-full flex flex-col items-start self-stretch gap-2 py-10
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ transform: `translateY(${translateY()})` }}
>
<For each={finishedItems()}>
{(part) => {
const message = createMemo(() =>
sync.data.message[part.sessionID].find(
(m) => m.id === part.messageID,
),
)
return (
<div class="h-10 flex items-center w-full">
<Switch>
<Match when={part.type === "text" && part}>
{(p) => (
<div
textContent={p().text}
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
/>
)}
</Match>
<Match when={part.type === "reasoning" && part}>
{(p) => <Part message={message()!} part={p()} />}
</Match>
<Match when={part.type === "tool" && part}>
{(p) => <Part message={message()!} part={p()} />}
</Match>
</Switch>
</div>
)
}}
</For>
</div>
</div>
</div>
)
}}
</Match> </Match>
<Match when={!working()}> <Match when={!working()}>
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}> <Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>

View File

@@ -16,16 +16,6 @@ export function DiffChanges(props: { diff: FileDiff | FileDiff[]; variant?: "def
) )
const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0)) const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0))
const countLines = (text: string) => {
if (!text) return 0
return text.split("\n").length
}
const totalBeforeLines = createMemo(() => {
if (!Array.isArray(props.diff)) return countLines(props.diff.before || "")
return props.diff.reduce((acc, diff) => acc + countLines(diff.before || ""), 0)
})
const blockCounts = createMemo(() => { const blockCounts = createMemo(() => {
const TOTAL_BLOCKS = 5 const TOTAL_BLOCKS = 5

View File

@@ -4,6 +4,7 @@
overflow: auto; overflow: auto;
scrollbar-width: none; scrollbar-width: none;
color: var(--text-base); color: var(--text-base);
text-wrap: pretty;
/* text-14-regular */ /* text-14-regular */
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
@@ -34,4 +35,10 @@
margin-top: 16px; margin-top: 16px;
margin-bottom: 16px; margin-bottom: 16px;
} }
hr {
margin-top: 8px;
margin-bottom: 16px;
border-color: var(--border-weaker-base);
}
} }

View File

@@ -0,0 +1,13 @@
:root {
--animate-pulse: pulse-opacity 2s ease-in-out infinite;
}
@keyframes pulse-opacity {
0%,
100% {
opacity: 0;
}
50% {
opacity: 1;
}
}

View File

@@ -28,3 +28,4 @@
@import "../components/typewriter.css" layer(components); @import "../components/typewriter.css" layer(components);
@import "./utilities.css" layer(utilities); @import "./utilities.css" layer(utilities);
@import "./animations.css" layer(utilities);

View File

@@ -64,6 +64,8 @@
--shadow-xs: var(--shadow-xs); --shadow-xs: var(--shadow-xs);
--shadow-md: var(--shadow-md); --shadow-md: var(--shadow-md);
--shadow-xs-border-selected: var(--shadow-xs-border-selected); --shadow-xs-border-selected: var(--shadow-xs-border-selected);
--animate-pulse: var(--animate-pulse);
} }
@import "./colors.css"; @import "./colors.css";

View File

@@ -48,71 +48,6 @@
border-width: 0; border-width: 0;
} }
.scroller {
/* --fade-height: 1.5rem; */
/**/
/* --mask-top: linear-gradient(to bottom, transparent, black var(--fade-height)); */
/* --mask-bottom: linear-gradient(to top, transparent, black var(--fade-height)); */
/**/
/* mask-image: var(--mask-top), var(--mask-bottom); */
/* mask-repeat: no-repeat; */
/* mask-size: 100% var(--fade-height); */
animation: scroll-fade linear;
animation-timeline: scroll(self);
}
/* Define the keyframes for the mask.
These percentages now map to scroll positions:
0% = Scrolled to the top
100% = Scrolled to the bottom
*/
@keyframes scroll-fade {
/* At the very top (0% scroll) */
0% {
mask-image: linear-gradient(
to bottom,
black 90%,
/* Opaque, but start fade to bottom */ transparent 100%
);
}
/* A small amount scrolled (e.g., 5%)
This is where the top fade should be fully visible.
*/
5% {
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 10%,
/* Fade-in top */ black 90%,
/* Fade-out bottom */ transparent 100%
);
}
/* Nearing the bottom (e.g., 95%)
The bottom fade should start disappearing.
*/
95% {
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 10%,
/* Fade-in top */ black 90%,
/* Fade-out bottom */ transparent 100%
);
}
/* At the very bottom (100% scroll) */
100% {
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 10% /* Opaque, but start fade from top */
);
}
}
.truncate-start { .truncate-start {
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;