Skip to content

Commit ffc889b

Browse files
committed
wip: desktop work
1 parent 36b48a4 commit ffc889b

File tree

9 files changed

+174
-175
lines changed

9 files changed

+174
-175
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { For, Match, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
2+
import { Part } from "@opencode-ai/ui"
3+
import { useSync } from "@/context/sync"
4+
import type { AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
5+
6+
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[] }) {
7+
const sync = useSync()
8+
const items = createMemo(() => props.assistantMessages().flatMap((m) => sync.data.part[m.id]))
9+
10+
const finishedItems = createMemo(() => [
11+
"",
12+
"",
13+
"Loading...",
14+
...items().filter(
15+
(p) =>
16+
p?.type === "text" ||
17+
(p?.type === "reasoning" && p.time?.end) ||
18+
(p?.type === "tool" && p.state.status === "completed"),
19+
),
20+
"",
21+
])
22+
23+
const MINIMUM_DELAY = 400
24+
const [visibleCount, setVisibleCount] = createSignal(1)
25+
26+
createEffect(() => {
27+
const total = finishedItems().length
28+
if (total > visibleCount()) {
29+
const timer = setTimeout(() => {
30+
setVisibleCount((prev) => prev + 1)
31+
}, MINIMUM_DELAY)
32+
onCleanup(() => clearTimeout(timer))
33+
} else if (total < visibleCount()) {
34+
setVisibleCount(total)
35+
}
36+
})
37+
38+
const translateY = createMemo(() => {
39+
const total = visibleCount()
40+
if (total < 2) return "0px"
41+
return `-${(total - 2) * 40 - 8}px`
42+
})
43+
44+
return (
45+
<div
46+
class="h-30 overflow-hidden pointer-events-none
47+
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
48+
mask-b-from-90% mask-b-from-background-base mask-b-to-transparent"
49+
>
50+
<div
51+
class="w-full flex flex-col items-start self-stretch gap-2 py-8
52+
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
53+
style={{ transform: `translateY(${translateY()})` }}
54+
>
55+
<For each={finishedItems()}>
56+
{(part) => {
57+
if (typeof part === "string") return <div class="h-8 flex items-center w-full">{part}</div>
58+
const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID))
59+
return (
60+
<div class="h-8 flex items-center w-full">
61+
<Switch>
62+
<Match when={part.type === "text" && part}>
63+
{(p) => (
64+
<div
65+
textContent={p().text}
66+
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
67+
/>
68+
)}
69+
</Match>
70+
<Match when={part.type === "reasoning" && part}>
71+
{(p) => <Part message={message()!} part={p()} />}
72+
</Match>
73+
<Match when={part.type === "tool" && part}>{(p) => <Part message={message()!} part={p()} />}</Match>
74+
</Switch>
75+
</div>
76+
)
77+
}}
78+
</For>
79+
</div>
80+
</div>
81+
)
82+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { ComponentProps, For } from "solid-js"
2+
3+
export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) {
4+
const squares = Array.from({ length: 16 }, (_, i) => ({
5+
id: i,
6+
x: (i % 4) * 4,
7+
y: Math.floor(i / 4) * 4,
8+
delay: Math.random() * 3,
9+
duration: 2 + Math.random() * 2,
10+
}))
11+
12+
return (
13+
<svg
14+
viewBox="0 0 15 15"
15+
classList={{
16+
"size-4": true,
17+
...(props.classList ?? {}),
18+
[props.class ?? ""]: !!props.class,
19+
}}
20+
fill="currentColor"
21+
>
22+
<For each={squares}>
23+
{(square) => (
24+
<rect
25+
x={square.x}
26+
y={square.y}
27+
width="3"
28+
height="3"
29+
rx="1"
30+
style={{
31+
animation: `pulse-opacity ${square.duration}s ease-in-out infinite`,
32+
"animation-delay": `${square.delay}s`,
33+
}}
34+
/>
35+
)}
36+
</For>
37+
</svg>
38+
)
39+
}

packages/desktop/src/pages/index.tsx

Lines changed: 30 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "@opencode-ai/ui"
1818
import { FileIcon } from "@/ui"
1919
import FileTree from "@/components/file-tree"
20+
import { MessageProgress } from "@/components/message-progress"
2021
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
2122
import { useLocal, type LocalFile } from "@/context/local"
2223
import { createStore } from "solid-js/store"
@@ -39,6 +40,7 @@ import { useSync } from "@/context/sync"
3940
import { useSDK } from "@/context/sdk"
4041
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
4142
import { Markdown } from "@opencode-ai/ui"
43+
import { Spinner } from "@/components/spinner"
4244

4345
export default function Page() {
4446
const local = useLocal()
@@ -546,21 +548,31 @@ export default function Page() {
546548
<For each={local.session.userMessages()}>
547549
{(message) => {
548550
const diffs = createMemo(() => message.summary?.diffs ?? [])
551+
const working = createMemo(() => !message.summary?.title)
549552
return (
550-
<li
551-
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
552-
onClick={() => local.session.setActiveMessage(message.id)}
553-
>
554-
<DiffChanges diff={diffs()} variant="bars" />
555-
<div
556-
data-active={local.session.activeMessage()?.id === message.id}
557-
classList={{
558-
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
559-
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
560-
}}
553+
<li class="group/li flex items-center self-stretch">
554+
<button
555+
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
556+
onClick={() => local.session.setActiveMessage(message.id)}
561557
>
562-
{message.summary?.title ?? local.session.getMessageText(message)}
563-
</div>
558+
<Switch>
559+
<Match when={working()}>
560+
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
561+
</Match>
562+
<Match when={true}>
563+
<DiffChanges diff={diffs()} variant="bars" />
564+
</Match>
565+
</Switch>
566+
<div
567+
data-active={local.session.activeMessage()?.id === message.id}
568+
classList={{
569+
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
570+
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
571+
}}
572+
>
573+
{message.summary?.title ?? local.session.getMessageText(message)}
574+
</div>
575+
</button>
564576
</li>
565577
)
566578
}}
@@ -600,19 +612,15 @@ export default function Page() {
600612
</Show>
601613
</div>
602614
</div>
603-
<Show when={title}>
604-
<div class="-mt-8">
605-
<Message message={message} parts={parts()} />
606-
</div>
607-
</Show>
615+
<div class="-mt-8">
616+
<Message message={message} parts={parts()} />
617+
</div>
608618
{/* Summary */}
609619
<Show when={!working()}>
610620
<div class="w-full flex flex-col gap-6 items-start self-stretch">
611621
<div class="flex flex-col items-start gap-1 self-stretch">
612622
<h2 class="text-12-medium text-text-weak">Summary</h2>
613-
<Show when={summary()}>
614-
<Markdown text={summary()!} />
615-
</Show>
623+
<Show when={summary()}>{(summary) => <Markdown text={summary()} />}</Show>
616624
</div>
617625
<Accordion class="w-full" multiple>
618626
<For each={message.summary?.diffs || []}>
@@ -666,85 +674,7 @@ export default function Page() {
666674
<div class="w-full">
667675
<Switch>
668676
<Match when={working()}>
669-
{(_) => {
670-
const items = createMemo(() =>
671-
assistantMessages().flatMap((m) => sync.data.part[m.id]),
672-
)
673-
const finishedItems = createMemo(() =>
674-
items().filter(
675-
(p) =>
676-
(p?.type === "text" && p.time?.end) ||
677-
(p?.type === "reasoning" && p.time?.end) ||
678-
(p?.type === "tool" && p.state.status === "completed"),
679-
),
680-
)
681-
682-
const MINIMUM_DELAY = 800
683-
const [visibleCount, setVisibleCount] = createSignal(1)
684-
685-
createEffect(() => {
686-
const total = finishedItems().length
687-
if (total > visibleCount()) {
688-
const timer = setTimeout(() => {
689-
setVisibleCount((prev) => prev + 1)
690-
}, MINIMUM_DELAY)
691-
onCleanup(() => clearTimeout(timer))
692-
} else if (total < visibleCount()) {
693-
setVisibleCount(total)
694-
}
695-
})
696-
697-
const translateY = createMemo(() => {
698-
const total = visibleCount()
699-
if (total < 2) return "0px"
700-
return `-${(total - 2) * 48 - 8}px`
701-
})
702-
703-
return (
704-
<div class="flex flex-col gap-3">
705-
<div
706-
class="h-36 overflow-hidden pointer-events-none
707-
mask-alpha mask-y-from-66% mask-y-from-background-base mask-y-to-transparent"
708-
>
709-
<div
710-
class="w-full flex flex-col items-start self-stretch gap-2 py-10
711-
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
712-
style={{ transform: `translateY(${translateY()})` }}
713-
>
714-
<For each={finishedItems()}>
715-
{(part) => {
716-
const message = createMemo(() =>
717-
sync.data.message[part.sessionID].find(
718-
(m) => m.id === part.messageID,
719-
),
720-
)
721-
return (
722-
<div class="h-10 flex items-center w-full">
723-
<Switch>
724-
<Match when={part.type === "text" && part}>
725-
{(p) => (
726-
<div
727-
textContent={p().text}
728-
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
729-
/>
730-
)}
731-
</Match>
732-
<Match when={part.type === "reasoning" && part}>
733-
{(p) => <Part message={message()!} part={p()} />}
734-
</Match>
735-
<Match when={part.type === "tool" && part}>
736-
{(p) => <Part message={message()!} part={p()} />}
737-
</Match>
738-
</Switch>
739-
</div>
740-
)
741-
}}
742-
</For>
743-
</div>
744-
</div>
745-
</div>
746-
)
747-
}}
677+
<MessageProgress assistantMessages={assistantMessages} />
748678
</Match>
749679
<Match when={!working()}>
750680
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>

packages/ui/src/components/diff-changes.tsx

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,6 @@ export function DiffChanges(props: { diff: FileDiff | FileDiff[]; variant?: "def
1616
)
1717
const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0))
1818

19-
const countLines = (text: string) => {
20-
if (!text) return 0
21-
return text.split("\n").length
22-
}
23-
24-
const totalBeforeLines = createMemo(() => {
25-
if (!Array.isArray(props.diff)) return countLines(props.diff.before || "")
26-
return props.diff.reduce((acc, diff) => acc + countLines(diff.before || ""), 0)
27-
})
28-
2919
const blockCounts = createMemo(() => {
3020
const TOTAL_BLOCKS = 5
3121

packages/ui/src/components/markdown.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
overflow: auto;
55
scrollbar-width: none;
66
color: var(--text-base);
7+
text-wrap: pretty;
78

89
/* text-14-regular */
910
font-family: var(--font-family-sans);
@@ -34,4 +35,10 @@
3435
margin-top: 16px;
3536
margin-bottom: 16px;
3637
}
38+
39+
hr {
40+
margin-top: 8px;
41+
margin-bottom: 16px;
42+
border-color: var(--border-weaker-base);
43+
}
3744
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
:root {
2+
--animate-pulse: pulse-opacity 2s ease-in-out infinite;
3+
}
4+
5+
@keyframes pulse-opacity {
6+
0%,
7+
100% {
8+
opacity: 0;
9+
}
10+
50% {
11+
opacity: 1;
12+
}
13+
}

packages/ui/src/styles/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@
2828
@import "../components/typewriter.css" layer(components);
2929

3030
@import "./utilities.css" layer(utilities);
31+
@import "./animations.css" layer(utilities);

packages/ui/src/styles/tailwind/index.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
--shadow-xs: var(--shadow-xs);
6565
--shadow-md: var(--shadow-md);
6666
--shadow-xs-border-selected: var(--shadow-xs-border-selected);
67+
68+
--animate-pulse: var(--animate-pulse);
6769
}
6870

6971
@import "./colors.css";

0 commit comments

Comments
 (0)