diff options
| author | David Hill <[email protected]> | 2025-10-31 17:02:53 +0000 |
|---|---|---|
| committer | David Hill <[email protected]> | 2025-10-31 17:02:53 +0000 |
| commit | b022cf0ed60d40e11174e6e312ba50e32ed722e3 (patch) | |
| tree | 3b3c3a69d5da4f4c7bf9bd8829430ca614fbb3a3 | |
| parent | a529b0324d462967d4502555374aaac8b588113a (diff) | |
| parent | 76e080b2cbe13b260324461eb2705d18fabfed35 (diff) | |
| download | opencode-b022cf0ed60d40e11174e6e312ba50e32ed722e3.tar.gz opencode-b022cf0ed60d40e11174e6e312ba50e32ed722e3.zip | |
Merge branch 'dev' of https://github.com/sst/opencode into dev
| -rwxr-xr-x | install | 12 | ||||
| -rw-r--r-- | packages/desktop/src/components/message-progress.tsx | 82 | ||||
| -rw-r--r-- | packages/desktop/src/components/spinner.tsx | 39 | ||||
| -rw-r--r-- | packages/desktop/src/pages/index.tsx | 147 | ||||
| -rw-r--r-- | packages/ui/src/components/diff-changes.tsx | 10 | ||||
| -rw-r--r-- | packages/ui/src/components/markdown.css | 7 | ||||
| -rw-r--r-- | packages/ui/src/styles/animations.css | 13 | ||||
| -rw-r--r-- | packages/ui/src/styles/index.css | 1 | ||||
| -rw-r--r-- | packages/ui/src/styles/tailwind/index.css | 2 | ||||
| -rw-r--r-- | packages/ui/src/styles/utilities.css | 65 |
10 files changed, 196 insertions, 182 deletions
@@ -10,10 +10,14 @@ NC='\033[0m' # No Color requested_version=${VERSION:-} -os=$(uname -s | tr '[:upper:]' '[:lower:]') -if [[ "$os" == "darwin" ]]; then - os="darwin" -fi +raw_os=$(uname -s) +os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]') +# Normalize various Unix-like identifiers +case "$raw_os" in + Darwin*) os="darwin" ;; + Linux*) os="linux" ;; + MINGW*|MSYS*|CYGWIN*) os="windows" ;; + esac arch=$(uname -m) if [[ "$arch" == "aarch64" ]]; then diff --git a/packages/desktop/src/components/message-progress.tsx b/packages/desktop/src/components/message-progress.tsx new file mode 100644 index 000000000..f77e196b5 --- /dev/null +++ b/packages/desktop/src/components/message-progress.tsx @@ -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> + ) +} diff --git a/packages/desktop/src/components/spinner.tsx b/packages/desktop/src/components/spinner.tsx new file mode 100644 index 000000000..5fc4cda64 --- /dev/null +++ b/packages/desktop/src/components/spinner.tsx @@ -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> + ) +} diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 5b2f4ecc1..2b723c55b 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -17,6 +17,7 @@ import { } from "@opencode-ai/ui" import { FileIcon } from "@/ui" 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 { useLocal, type LocalFile } from "@/context/local" import { createStore } from "solid-js/store" @@ -39,6 +40,7 @@ import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" import { Markdown } from "@opencode-ai/ui" +import { Spinner } from "@/components/spinner" export default function Page() { const local = useLocal() @@ -546,21 +548,31 @@ export default function Page() { <For each={local.session.userMessages()}> {(message) => { const diffs = createMemo(() => message.summary?.diffs ?? []) + const working = createMemo(() => !message.summary?.title) return ( - <li - class="group/li flex items-center gap-x-2 py-1 self-stretch 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, - }} + <li class="group/li flex items-center self-stretch"> + <button + class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default" + onClick={() => local.session.setActiveMessage(message.id)} > - {message.summary?.title ?? local.session.getMessageText(message)} - </div> + <Switch> + <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> ) }} @@ -576,11 +588,17 @@ export default function Page() { const parts = createMemo(() => sync.data.part[message.id]) const title = createMemo(() => message.summary?.title) const summary = createMemo(() => message.summary?.body) + const diffs = createMemo(() => message.summary?.diffs ?? []) const assistantMessages = createMemo(() => { return sync.data.message[activeSession().id]?.filter( (m) => m.role === "assistant" && m.parentID == message.id, ) as AssistantMessageType[] }) + const hasToolPart = createMemo(() => + assistantMessages() + ?.flatMap((m) => sync.data.part[m.id]) + .some((p) => p.type === "tool"), + ) const working = createMemo(() => !summary()) createEffect(() => { setTimeout(() => setInitialized(!!title()), 10_000) @@ -600,22 +618,23 @@ export default function Page() { </Show> </div> </div> - <Show when={title}> - <div class="-mt-8"> - <Message message={message} parts={parts()} /> - </div> - </Show> + <div class="-mt-8"> + <Message message={message} parts={parts()} /> + </div> {/* Summary */} <Show when={!working()}> <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">Summary</h2> - <Show when={summary()}> - <Markdown text={summary()!} /> - </Show> + <h2 class="text-12-medium text-text-weak"> + <Switch> + <Match when={diffs().length}>Summary</Match> + <Match when={true}>Response</Match> + </Switch> + </h2> + <Show when={summary()}>{(summary) => <Markdown text={summary()} />}</Show> </div> <Accordion class="w-full" multiple> - <For each={message.summary?.diffs || []}> + <For each={diffs()}> {(diff) => ( <Accordion.Item value={diff.file}> <Accordion.Header> @@ -666,87 +685,9 @@ export default function Page() { <div class="w-full"> <Switch> <Match when={working()}> - {(_) => { - 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> - ) - }} + <MessageProgress assistantMessages={assistantMessages} /> </Match> - <Match when={!working()}> + <Match when={!working() && hasToolPart()}> <Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}> <Collapsible.Trigger class="text-text-weak hover:text-text-strong"> <div class="flex items-center gap-1 self-stretch"> diff --git a/packages/ui/src/components/diff-changes.tsx b/packages/ui/src/components/diff-changes.tsx index 433c47f39..e6c04f519 100644 --- a/packages/ui/src/components/diff-changes.tsx +++ b/packages/ui/src/components/diff-changes.tsx @@ -16,16 +16,6 @@ export function DiffChanges(props: { diff: FileDiff | FileDiff[]; variant?: "def ) 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 TOTAL_BLOCKS = 5 diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index abc505a9e..6af0f550e 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -4,6 +4,7 @@ overflow: auto; scrollbar-width: none; color: var(--text-base); + text-wrap: pretty; /* text-14-regular */ font-family: var(--font-family-sans); @@ -34,4 +35,10 @@ margin-top: 16px; margin-bottom: 16px; } + + hr { + margin-top: 8px; + margin-bottom: 16px; + border-color: var(--border-weaker-base); + } } diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css new file mode 100644 index 000000000..ba93e65e4 --- /dev/null +++ b/packages/ui/src/styles/animations.css @@ -0,0 +1,13 @@ +:root { + --animate-pulse: pulse-opacity 2s ease-in-out infinite; +} + +@keyframes pulse-opacity { + 0%, + 100% { + opacity: 0; + } + 50% { + opacity: 1; + } +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 146d957e2..e3cffc6cc 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -28,3 +28,4 @@ @import "../components/typewriter.css" layer(components); @import "./utilities.css" layer(utilities); +@import "./animations.css" layer(utilities); diff --git a/packages/ui/src/styles/tailwind/index.css b/packages/ui/src/styles/tailwind/index.css index 76d8c7d3e..658809df4 100644 --- a/packages/ui/src/styles/tailwind/index.css +++ b/packages/ui/src/styles/tailwind/index.css @@ -64,6 +64,8 @@ --shadow-xs: var(--shadow-xs); --shadow-md: var(--shadow-md); --shadow-xs-border-selected: var(--shadow-xs-border-selected); + + --animate-pulse: var(--animate-pulse); } @import "./colors.css"; diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 9c6b73f9c..99b7760a0 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -48,71 +48,6 @@ 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 { text-overflow: ellipsis; overflow: hidden; |
