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 { Part } from "@opencode-ai/ui"
import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { Markdown, Part } from "@opencode-ai/ui"
import { useSync } from "@/context/sync"
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 }) {
const sync = useSync()
@@ -22,37 +23,42 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
) as ToolPart,
)
const eligibleItems = createMemo(() => {
let allParts = parts()
const resolvedParts = createMemo(() => {
let resolved = parts()
const task = currentTask()
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")
allParts = messages?.flatMap((m) => sync.data.part[m.id]) ?? parts()
resolved = messages?.flatMap((m) => sync.data.part[m.id]) ?? parts()
}
return allParts.filter(
(p) =>
p?.type === "text" ||
(p?.type === "reasoning" && p.time?.end) ||
(p?.type === "tool" && p.state.status === "completed"),
)
return resolved
})
const currentText = createMemo(
() =>
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)[]>(() => [
"",
"",
<div class="text-text-diff-add-base">Loading...</div>,
<div class="h-8 w-full" />,
<div class="h-8 w-full" />,
<div class="flex items-center gap-x-5 pl-3 text-text-base">
<Spinner /> <span class="text-12-medium">Thinking...</span>
</div>,
...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 [visibleCount, setVisibleCount] = createSignal(1)
const delay = createMemo(() => (done() ? 220 : 400))
const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length)
createEffect(() => {
const total = finishedItems().length
if (total > visibleCount()) {
const timer = setTimeout(() => {
setVisibleCount((prev) => prev + 1)
}, MINIMUM_DELAY)
}, delay())
onCleanup(() => clearTimeout(timer))
} else if (total < visibleCount()) {
setVisibleCount(total)
@@ -66,43 +72,57 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
})
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="flex flex-col gap-3">
<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()})` }}
class="h-30 overflow-hidden pointer-events-none pb-1
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
mask-b-from-95% mask-b-from-background-base mask-b-to-transparent"
>
<For each={finishedItems()}>
{(part) => {
if (part && typeof part === "object" && "type" in part) {
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>
)
}
return <div class="h-8 flex items-center w-full">{part}</div>
}}
</For>
<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 (part && typeof part === "object" && "type" in part) {
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>
)
}
return <div class="h-8 flex items-center w-full">{part}</div>
}}
</For>
</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>
)
}

View File

@@ -334,7 +334,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSubmit={handleSubmit}
classList={{
"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,
}}
>

View File

@@ -548,7 +548,79 @@ export default function Page() {
<For each={local.session.userMessages()}>
{(message) => {
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 (
<li class="group/li flex items-center self-stretch">
<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,
}}
>
{message.summary?.title ?? local.session.getMessageText(message)}
<Switch>
<Match when={working()}>{status()}</Match>
<Match when={true}>{message.summary?.title}</Match>
</Switch>
</div>
</button>
</li>
@@ -604,10 +679,12 @@ export default function Page() {
// allowing time for the animations to finish
createEffect(() => {
title()
setTimeout(() => setTitled(!!title()), 10_000)
})
createEffect(() => {
setTimeout(() => setCompleted(!!summary()), 3_000)
summary()
setTimeout(() => setCompleted(!!summary()), 1200)
})
return (
@@ -618,9 +695,18 @@ export default function Page() {
>
{/* Title */}
<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">
<Show when={titled()} fallback={<Typewriter as="h1" text={title()} />}>
<h1>{title()}</h1>
<div class="w-full text-14-medium text-text-strong">
<Show
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>
</div>
</div>
@@ -628,7 +714,7 @@ export default function Page() {
<Message message={message} parts={parts()} />
</div>
{/* Summary */}
<Show when={!working()}>
<Show when={completed()}>
<div class="w-full flex flex-col gap-6 items-start self-stretch">
<div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">
@@ -637,7 +723,9 @@ export default function Page() {
<Match when={true}>Response</Match>
</Switch>
</h2>
<Show when={summary()}>{(summary) => <Markdown text={summary()} />}</Show>
<Show when={summary()}>
{(summary) => <Markdown class="[&>*]:fade-up-text" text={summary()} />}
</Show>
</div>
<Accordion class="w-full" multiple>
<For each={diffs()}>
@@ -699,8 +787,8 @@ export default function Page() {
<div class="flex items-center gap-1 self-stretch">
<div class="text-12-medium">
<Switch>
<Match when={expanded()}>Hide steps</Match>
<Match when={!expanded()}>Show steps</Match>
<Match when={expanded()}>Hide details</Match>
<Match when={!expanded()}>Show details</Match>
</Switch>
</div>
<Collapsible.Arrow />