wip: desktop work

This commit is contained in:
Adam
2025-10-31 15:37:45 -05:00
parent e1aed0cd01
commit 342aa27e03
7 changed files with 378 additions and 67 deletions

View File

@@ -1,7 +1,8 @@
import { For, JSXElement, Match, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { Part } from "@opencode-ai/ui" import { Markdown, Part } from "@opencode-ai/ui"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import type { AssistantMessage as AssistantMessageType, Part as PartType, ToolPart } from "@opencode-ai/sdk" import type { AssistantMessage as AssistantMessageType, Part as PartType, ToolPart } from "@opencode-ai/sdk"
import { Spinner } from "./spinner"
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) { export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
const sync = useSync() const sync = useSync()
@@ -22,37 +23,42 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
) as ToolPart, ) as ToolPart,
) )
const eligibleItems = createMemo(() => { const resolvedParts = createMemo(() => {
let allParts = parts() let resolved = parts()
const task = currentTask() const task = currentTask()
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
const messages = sync.data.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant") const messages = sync.data.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant")
allParts = messages?.flatMap((m) => sync.data.part[m.id]) ?? parts() resolved = messages?.flatMap((m) => sync.data.part[m.id]) ?? parts()
} }
return allParts.filter( return resolved
(p) => })
p?.type === "text" || const currentText = createMemo(
(p?.type === "reasoning" && p.time?.end) || () =>
(p?.type === "tool" && p.state.status === "completed"), resolvedParts().findLast((p) => p?.type === "text")?.text ||
) resolvedParts().findLast((p) => p?.type === "reasoning")?.text,
)
const eligibleItems = createMemo(() => {
return resolvedParts().filter((p) => p?.type === "tool" && p.state.status === "completed")
}) })
const finishedItems = createMemo<(JSXElement | PartType)[]>(() => [ const finishedItems = createMemo<(JSXElement | PartType)[]>(() => [
"", <div class="h-8 w-full" />,
"", <div class="h-8 w-full" />,
<div class="text-text-diff-add-base">Loading...</div>, <div class="flex items-center gap-x-5 pl-3 text-text-base">
<Spinner /> <span class="text-12-medium">Thinking...</span>
</div>,
...eligibleItems(), ...eligibleItems(),
...(done() ? ["", "", ""] : []), ...(done() ? [<div class="h-8 w-full" />, <div class="h-8 w-full" />, <div class="h-8 w-full" />] : []),
]) ])
const MINIMUM_DELAY = 400 const delay = createMemo(() => (done() ? 220 : 400))
const [visibleCount, setVisibleCount] = createSignal(1) const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length)
createEffect(() => { createEffect(() => {
const total = finishedItems().length const total = finishedItems().length
if (total > visibleCount()) { if (total > visibleCount()) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setVisibleCount((prev) => prev + 1) setVisibleCount((prev) => prev + 1)
}, MINIMUM_DELAY) }, delay())
onCleanup(() => clearTimeout(timer)) onCleanup(() => clearTimeout(timer))
} else if (total < visibleCount()) { } else if (total < visibleCount()) {
setVisibleCount(total) setVisibleCount(total)
@@ -66,43 +72,57 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
}) })
return ( return (
<div <div class="flex flex-col gap-3">
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 <div
class="w-full flex flex-col items-start self-stretch gap-2 py-8 class="h-30 overflow-hidden pointer-events-none pb-1
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]" mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
style={{ transform: `translateY(${translateY()})` }} mask-b-from-95% mask-b-from-background-base mask-b-to-transparent"
> >
<For each={finishedItems()}> <div
{(part) => { class="w-full flex flex-col items-start self-stretch gap-2 py-8
if (part && typeof part === "object" && "type" in part) { transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID)) style={{ transform: `translateY(${translateY()})` }}
return ( >
<div class="h-8 flex items-center w-full"> <For each={finishedItems()}>
<Switch> {(part) => {
<Match when={part.type === "text" && part}> if (part && typeof part === "object" && "type" in part) {
{(p) => ( const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID))
<div return (
textContent={p().text} <div class="h-8 flex items-center w-full">
class="text-12-regular text-text-base whitespace-nowrap truncate w-full" <Switch>
/> <Match when={part.type === "text" && part}>
)} {(p) => (
</Match> <div
<Match when={part.type === "reasoning" && part}> textContent={p().text}
{(p) => <Part message={message()!} part={p()} />} class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
</Match> />
<Match when={part.type === "tool" && part}>{(p) => <Part message={message()!} part={p()} />}</Match> )}
</Switch> </Match>
</div> <Match when={part.type === "reasoning" && part}>
) {(p) => <Part message={message()!} part={p()} />}
} </Match>
return <div class="h-8 flex items-center w-full">{part}</div> <Match when={part.type === "tool" && part}>
}} {(p) => <Part message={message()!} part={p()} />}
</For> </Match>
</Switch>
</div>
)
}
return <div class="h-8 flex items-center w-full">{part}</div>
}}
</For>
</div>
</div> </div>
<Show when={currentText()}>
{(text) => (
<div
class="max-h-36 flex flex-col justify-end overflow-hidden py-3
mask-alpha mask-t-from-80% mask-t-from-background-base mask-t-to-transparent"
>
<Markdown text={text()} class="w-full shrink-0 overflow-visible" />
</div>
)}
</Show>
</div> </div>
) )
} }

View File

@@ -334,7 +334,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSubmit={handleSubmit} onSubmit={handleSubmit}
classList={{ classList={{
"bg-surface-raised-stronger-non-alpha border border-border-strong-base": true, "bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
"rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true, "rounded-2xl overflow-clip focus-within:border-transparent focus-within:shadow-xs-border-select": true,
[props.class ?? ""]: !!props.class, [props.class ?? ""]: !!props.class,
}} }}
> >

View File

@@ -548,7 +548,79 @@ 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) const working = createMemo(() => !message.summary?.body)
const assistantMessages = createMemo(() => {
return sync.data.message[activeSession().id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const parts = createMemo(() =>
assistantMessages().flatMap((m) => sync.data.part[m.id]),
)
const lastPart = createMemo(() => parts().slice(-1)?.at(0))
const rawStatus = createMemo(() => {
const defaultStatus = "Working..."
const last = lastPart()
if (!last) return defaultStatus
if (last.type === "tool") {
switch (last.tool) {
case "task":
return "Delegating work..."
case "todowrite":
case "todoread":
return "Planning next steps..."
case "read":
return "Gathering context..."
case "list":
case "grep":
case "glob":
return "Searching the codebase..."
case "webfetch":
return "Searching the web..."
case "edit":
case "write":
return "Making edits..."
case "bash":
return "Running commands..."
default:
break
}
} else if (last.type === "reasoning") {
return "Thinking..."
} else if (last.type === "text") {
return "Gathering thoughts..."
}
return defaultStatus
})
const [status, setStatus] = createSignal(rawStatus())
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === status()) return
const timeSinceLastChange = Date.now() - lastStatusChange
if (timeSinceLastChange >= 1000) {
setStatus(newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
setStatus(rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 1000 - timeSinceLastChange) as unknown as number
}
})
return ( return (
<li class="group/li flex items-center self-stretch"> <li class="group/li flex items-center self-stretch">
<button <button
@@ -570,7 +642,10 @@ export default function Page() {
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": 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>
<Match when={working()}>{status()}</Match>
<Match when={true}>{message.summary?.title}</Match>
</Switch>
</div> </div>
</button> </button>
</li> </li>
@@ -604,10 +679,12 @@ export default function Page() {
// allowing time for the animations to finish // allowing time for the animations to finish
createEffect(() => { createEffect(() => {
title()
setTimeout(() => setTitled(!!title()), 10_000) setTimeout(() => setTitled(!!title()), 10_000)
}) })
createEffect(() => { createEffect(() => {
setTimeout(() => setCompleted(!!summary()), 3_000) summary()
setTimeout(() => setCompleted(!!summary()), 1200)
}) })
return ( return (
@@ -618,9 +695,18 @@ export default function Page() {
> >
{/* Title */} {/* Title */}
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10"> <div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
<div class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0"> <div class="w-full text-14-medium text-text-strong">
<Show when={titled()} fallback={<Typewriter as="h1" text={title()} />}> <Show
<h1>{title()}</h1> when={titled()}
fallback={
<Typewriter
as="h1"
text={title()}
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
/>
}
>
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">{title()}</h1>
</Show> </Show>
</div> </div>
</div> </div>
@@ -628,7 +714,7 @@ export default function Page() {
<Message message={message} parts={parts()} /> <Message message={message} parts={parts()} />
</div> </div>
{/* Summary */} {/* Summary */}
<Show when={!working()}> <Show when={completed()}>
<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"> <h2 class="text-12-medium text-text-weak">
@@ -637,7 +723,9 @@ export default function Page() {
<Match when={true}>Response</Match> <Match when={true}>Response</Match>
</Switch> </Switch>
</h2> </h2>
<Show when={summary()}>{(summary) => <Markdown text={summary()} />}</Show> <Show when={summary()}>
{(summary) => <Markdown class="[&>*]:fade-up-text" text={summary()} />}
</Show>
</div> </div>
<Accordion class="w-full" multiple> <Accordion class="w-full" multiple>
<For each={diffs()}> <For each={diffs()}>
@@ -699,8 +787,8 @@ export default function Page() {
<div class="flex items-center gap-1 self-stretch"> <div class="flex items-center gap-1 self-stretch">
<div class="text-12-medium"> <div class="text-12-medium">
<Switch> <Switch>
<Match when={expanded()}>Hide steps</Match> <Match when={expanded()}>Hide details</Match>
<Match when={!expanded()}>Show steps</Match> <Match when={!expanded()}>Show details</Match>
</Switch> </Switch>
</div> </div>
<Collapsible.Arrow /> <Collapsible.Arrow />

View File

@@ -6,12 +6,12 @@
color: var(--text-base); color: var(--text-base);
text-wrap: pretty; text-wrap: pretty;
/* text-14-regular */ /* text-12-regular */
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-base); font-size: var(--font-size-small);
font-style: normal; font-style: normal;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); /* 142.857% */ line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
&::-webkit-scrollbar { &::-webkit-scrollbar {

View File

@@ -11,3 +11,110 @@
opacity: 1; opacity: 1;
} }
} }
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-up-text {
animation: fadeUp 0.4s ease-out forwards;
opacity: 0;
&:nth-child(1) {
animation-delay: 0.1s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
&:nth-child(4) {
animation-delay: 0.4s;
}
&:nth-child(5) {
animation-delay: 0.5s;
}
&:nth-child(6) {
animation-delay: 0.6s;
}
&:nth-child(7) {
animation-delay: 0.7s;
}
&:nth-child(8) {
animation-delay: 0.8s;
}
&:nth-child(9) {
animation-delay: 0.9s;
}
&:nth-child(10) {
animation-delay: 1s;
}
&:nth-child(11) {
animation-delay: 1.1s;
}
&:nth-child(12) {
animation-delay: 1.2s;
}
&:nth-child(13) {
animation-delay: 1.3s;
}
&:nth-child(14) {
animation-delay: 1.4s;
}
&:nth-child(15) {
animation-delay: 1.5s;
}
&:nth-child(16) {
animation-delay: 1.6s;
}
&:nth-child(17) {
animation-delay: 1.7s;
}
&:nth-child(18) {
animation-delay: 1.8s;
}
&:nth-child(19) {
animation-delay: 1.9s;
}
&:nth-child(20) {
animation-delay: 2s;
}
&:nth-child(21) {
animation-delay: 2.1s;
}
&:nth-child(22) {
animation-delay: 2.2s;
}
&:nth-child(23) {
animation-delay: 2.3s;
}
&:nth-child(24) {
animation-delay: 2.4s;
}
&:nth-child(25) {
animation-delay: 2.5s;
}
&:nth-child(26) {
animation-delay: 2.6s;
}
&:nth-child(27) {
animation-delay: 2.7s;
}
&:nth-child(28) {
animation-delay: 2.8s;
}
&:nth-child(29) {
animation-delay: 2.9s;
}
&:nth-child(30) {
animation-delay: 3s;
}
}

View File

@@ -63,7 +63,7 @@
--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-select: var(--shadow-xs-border-select);
--animate-pulse: var(--animate-pulse); --animate-pulse: var(--animate-pulse);
} }

View File

@@ -15,3 +15,99 @@
direction: rtl; direction: rtl;
text-align: left; text-align: left;
} }
@utility fade-up-text {
animation: fadeUp 0.4s ease-out forwards;
opacity: 0;
&:nth-child(1) {
animation-delay: 0.1s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
&:nth-child(4) {
animation-delay: 0.4s;
}
&:nth-child(5) {
animation-delay: 0.5s;
}
&:nth-child(6) {
animation-delay: 0.6s;
}
&:nth-child(7) {
animation-delay: 0.7s;
}
&:nth-child(8) {
animation-delay: 0.8s;
}
&:nth-child(9) {
animation-delay: 0.9s;
}
&:nth-child(10) {
animation-delay: 1s;
}
&:nth-child(11) {
animation-delay: 1.1s;
}
&:nth-child(12) {
animation-delay: 1.2s;
}
&:nth-child(13) {
animation-delay: 1.3s;
}
&:nth-child(14) {
animation-delay: 1.4s;
}
&:nth-child(15) {
animation-delay: 1.5s;
}
&:nth-child(16) {
animation-delay: 1.6s;
}
&:nth-child(17) {
animation-delay: 1.7s;
}
&:nth-child(18) {
animation-delay: 1.8s;
}
&:nth-child(19) {
animation-delay: 1.9s;
}
&:nth-child(20) {
animation-delay: 2s;
}
&:nth-child(21) {
animation-delay: 2.1s;
}
&:nth-child(22) {
animation-delay: 2.2s;
}
&:nth-child(23) {
animation-delay: 2.3s;
}
&:nth-child(24) {
animation-delay: 2.4s;
}
&:nth-child(25) {
animation-delay: 2.5s;
}
&:nth-child(26) {
animation-delay: 2.6s;
}
&:nth-child(27) {
animation-delay: 2.7s;
}
&:nth-child(28) {
animation-delay: 2.8s;
}
&:nth-child(29) {
animation-delay: 2.9s;
}
&:nth-child(30) {
animation-delay: 3s;
}
}