summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-10-31 15:37:45 -0500
committerAdam <[email protected]>2025-10-31 15:37:50 -0500
commit342aa27e03dd0db02b60a15a1779254bce395e19 (patch)
treeb735062a9fe1c673be733aa861876cfbc94b212d
parente1aed0cd01d23e433519621f8d21c0a8ffa3977d (diff)
downloadopencode-342aa27e03dd0db02b60a15a1779254bce395e19.tar.gz
opencode-342aa27e03dd0db02b60a15a1779254bce395e19.zip
wip: desktop work
-rw-r--r--packages/desktop/src/components/message-progress.tsx124
-rw-r--r--packages/desktop/src/components/prompt-input.tsx2
-rw-r--r--packages/desktop/src/pages/index.tsx108
-rw-r--r--packages/ui/src/components/markdown.css6
-rw-r--r--packages/ui/src/styles/animations.css107
-rw-r--r--packages/ui/src/styles/tailwind/index.css2
-rw-r--r--packages/ui/src/styles/tailwind/utilities.css96
7 files changed, 378 insertions, 67 deletions
diff --git a/packages/desktop/src/components/message-progress.tsx b/packages/desktop/src/components/message-progress.tsx
index fd66c5caf..5533ae413 100644
--- a/packages/desktop/src/components/message-progress.tsx
+++ b/packages/desktop/src/components/message-progress.tsx
@@ -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>
)
}
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 10487612b..9b2c10df7 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -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,
}}
>
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
index 929aeda7a..35e415aaf 100644
--- a/packages/desktop/src/pages/index.tsx
+++ b/packages/desktop/src/pages/index.tsx
@@ -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 />
diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css
index 6af0f550e..945c27648 100644
--- a/packages/ui/src/components/markdown.css
+++ b/packages/ui/src/components/markdown.css
@@ -6,12 +6,12 @@
color: var(--text-base);
text-wrap: pretty;
- /* text-14-regular */
+ /* text-12-regular */
font-family: var(--font-family-sans);
- font-size: var(--font-size-base);
+ font-size: var(--font-size-small);
font-style: normal;
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);
&::-webkit-scrollbar {
diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css
index ba93e65e4..5fcebb93f 100644
--- a/packages/ui/src/styles/animations.css
+++ b/packages/ui/src/styles/animations.css
@@ -11,3 +11,110 @@
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;
+ }
+}
diff --git a/packages/ui/src/styles/tailwind/index.css b/packages/ui/src/styles/tailwind/index.css
index 658809df4..d96240e78 100644
--- a/packages/ui/src/styles/tailwind/index.css
+++ b/packages/ui/src/styles/tailwind/index.css
@@ -63,7 +63,7 @@
--shadow-xs: var(--shadow-xs);
--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);
}
diff --git a/packages/ui/src/styles/tailwind/utilities.css b/packages/ui/src/styles/tailwind/utilities.css
index fbb407b1d..8194aeffb 100644
--- a/packages/ui/src/styles/tailwind/utilities.css
+++ b/packages/ui/src/styles/tailwind/utilities.css
@@ -15,3 +15,99 @@
direction: rtl;
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;
+ }
+}