From b207ed2b7b4080c3c6e0b2bc8430abcb4a894cad Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Thu, 25 Sep 2025 07:04:36 -0500
Subject: [PATCH] wip: better desktop file status state and timeline
---
packages/app/src/components/code.tsx | 2 +-
packages/app/src/components/markdown.tsx | 3 +-
.../app/src/components/session-timeline.tsx | 324 ++++++++++++------
packages/app/src/context/local.tsx | 90 +++--
packages/app/src/context/sync.tsx | 6 +-
packages/app/src/pages/index.tsx | 17 +-
packages/app/src/ui/icon.tsx | 5 +
7 files changed, 288 insertions(+), 159 deletions(-)
diff --git a/packages/app/src/components/code.tsx b/packages/app/src/components/code.tsx
index e4121d12..63f527c4 100644
--- a/packages/app/src/components/code.tsx
+++ b/packages/app/src/components/code.tsx
@@ -435,7 +435,7 @@ function transformerUnifiedDiff(): ShikiTransformer {
out.push(s)
}
- return out.join("\n")
+ return out.join("\n").trimEnd()
},
code(node) {
if (isDiff) this.addClassToHast(node, "code-diff")
diff --git a/packages/app/src/components/markdown.tsx b/packages/app/src/components/markdown.tsx
index ab6232ec..a60fad14 100644
--- a/packages/app/src/components/markdown.tsx
+++ b/packages/app/src/components/markdown.tsx
@@ -6,8 +6,7 @@ function strip(text: string): string {
const match = text.match(wrappedRe)
return match ? match[2] : text
}
-
-export default function Markdown(props: { text: string; class?: string }) {
+export function Markdown(props: { text: string; class?: string }) {
const marked = useMarked()
const [html] = createResource(
() => strip(props.text),
diff --git a/packages/app/src/components/session-timeline.tsx b/packages/app/src/components/session-timeline.tsx
index ac8519a9..99fa5fac 100644
--- a/packages/app/src/components/session-timeline.tsx
+++ b/packages/app/src/components/session-timeline.tsx
@@ -1,5 +1,5 @@
import { useLocal, useSync } from "@/context"
-import { Collapsible, Icon, type IconProps } from "@/ui"
+import { Collapsible, Icon } from "@/ui"
import type { Part, ToolPart } from "@opencode-ai/sdk"
import { DateTime } from "luxon"
import {
@@ -13,58 +13,14 @@ import {
type ParentProps,
createEffect,
createMemo,
+ Show,
} from "solid-js"
import { getFilename } from "@/utils"
-import Markdown from "./markdown"
+import { Markdown } from "./markdown"
import { Code } from "./code"
import { createElementSize } from "@solid-primitives/resize-observer"
import { createScrollPosition } from "@solid-primitives/scroll"
-function TimelineIcon(props: { name: IconProps["name"]; class?: string }) {
- return (
-
-
-
- )
-}
-
-function CollapsibleTimelineIcon(props: { name: IconProps["name"]; class?: string }) {
- return (
- <>
-
-
-
- >
- )
-}
-
-function ToolIcon(props: { part: ToolPart }) {
- return (
- }>
-
-
-
-
-
-
-
-
-
-
- )
-}
-
function Part(props: ParentProps & ComponentProps<"div">) {
const [local, others] = splitProps(props, ["class", "classList", "children"])
return (
@@ -97,9 +53,13 @@ function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps
}
function ReadToolPart(props: { part: ToolPart }) {
+ const sync = useSync()
const local = useLocal()
return (
+
+ Reading file...
+
{(state) => {
const path = state().input["filePath"] as string
@@ -110,13 +70,27 @@ function ReadToolPart(props: { part: ToolPart }) {
)
}}
+
+ {(state) => (
+
+
+ Read {getFilename(state().input["filePath"] as string)}
+
+
{sync.sanitize(state().error)}
+
+ )}
+
)
}
function EditToolPart(props: { part: ToolPart }) {
+ const sync = useSync()
return (
+
+ Preparing edit...
+
{(state) => (
)}
+
+ {(state) => (
+
+ Edit {getFilename(state().input["filePath"] as string)}
+ >
+ }
+ >
+ {sync.sanitize(state().error)}
+
+ )}
+
)
}
function WriteToolPart(props: { part: ToolPart }) {
+ const sync = useSync()
return (
+
+ Preparing write...
+
{(state) => (
)}
+
+ {(state) => (
+
+
+ Write {getFilename(state().input["filePath"] as string)}
+
+
{sync.sanitize(state().error)}
+
+ )}
+
+
+ )
+}
+
+function BashToolPart(props: { part: ToolPart }) {
+ const sync = useSync()
+ return (
+
+
+ Writing shell command...
+
+
+ {(state) => (
+
+ Run command: {state().input["command"]}
+ >
+ }
+ >
+
+
+ )}
+
+
+ {(state) => (
+
+ Shell {state().input["command"]}
+ >
+ }
+ >
+ {sync.sanitize(state().error)}
+
+ )}
+
)
}
function ToolPart(props: { part: ToolPart }) {
+ // read
+ // edit
+ // write
+ // bash
+ // ls
+ // glob
+ // grep
+ // todowrite
+ // todoread
+ // webfetch
+ // websearch
+ // patch
+ // task
return (
-
- {props.part.type}:{props.part.tool}
-
- }
- >
-
-
+
+
+ {props.part.type}:{props.part.tool}
+
+ }
+ >
+
-
-
-
-
+
+
-
-
-
-
+
+
-
-
-
+
+
+
+
+
+
)
}
@@ -196,6 +247,7 @@ export default function SessionTimeline(props: { session: string; class?: string
const scroll = createScrollPosition(scrollElement)
onMount(() => sync.session.sync(props.session))
+ const session = createMemo(() => sync.session.get(props.session))
const messages = createMemo(() => sync.data.message[props.session] ?? [])
const working = createMemo(() => {
const last = messages()[messages().length - 1]
@@ -285,60 +337,33 @@ export default function SessionTimeline(props: { session: string; class?: string
-
- {(message) => (
-
+
+
+ {(message) => (
{(part) => (
- -
-
-
-
-
- }
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
- {(part) => }
-
+
{part.type}}>
{(part) => (
-
+
{part().text}
-
12:07pm · adam
+
+ {DateTime.fromMillis(message.time.created).toRelative()} ·{" "}
+ {sync.data.config.username ?? "user"}
+
-
+
)}
@@ -347,9 +372,11 @@ export default function SessionTimeline(props: { session: string; class?: string
{(part) => (
- Thought for {duration(part())}s
- >
+ Thinking}>
+
+ Thought for {duration(part())}s
+
+
}
>
@@ -361,9 +388,84 @@ export default function SessionTimeline(props: { session: string; class?: string
)}
-
- )}
-
+ )}
+
+
+
+
+
+
+
+ Raw Session Data
+
+
+
+
+
+ -
+
+
+
+
+ session
+
+
+
+
+
+
+
+
+
+ {(message) => (
+ <>
+ -
+
+
+
+
+ {message.role === "user" ? "user" : "assistant"}
+
+
+
+
+
+
+
+
+
+ {(part) => (
+ -
+
+
+
+
+ {part.type}
+
+
+
+
+
+
+
+
+ )}
+
+ >
+ )}
+
+
+
+
+
)
}
diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx
index b512ef47..03d180a4 100644
--- a/packages/app/src/context/local.tsx
+++ b/packages/app/src/context/local.tsx
@@ -1,7 +1,7 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
import { uniqueBy } from "remeda"
-import type { FileContent, FileNode, Model, Provider } from "@opencode-ai/sdk"
+import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
import { useSDK, useEvent, useSync } from "@/context"
export type LocalFile = FileNode &
@@ -15,6 +15,7 @@ export type LocalFile = FileNode &
view: "raw" | "diff-unified" | "diff-split"
folded: string[]
selectedChange: number
+ status: FileStatus
}>
export type TextSelection = LocalFile["selection"]
export type View = LocalFile["view"]
@@ -126,9 +127,33 @@ function init() {
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
- const status = (path: string) => sync.data.changes.find((f) => f.path === path)
+
+ createEffect((prev: FileStatus[]) => {
+ const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
+ for (const p of removed) {
+ setStore(
+ "node",
+ p.path,
+ produce((draft) => {
+ draft.status = undefined
+ draft.view = "raw"
+ }),
+ )
+ load(p.path)
+ }
+ for (const p of sync.data.changes) {
+ if (store.node[p.path] === undefined) {
+ fetch(p.path).then(() => setStore("node", p.path, "status", p))
+ } else {
+ setStore("node", p.path, "status", p)
+ }
+ }
+ return sync.data.changes
+ }, sync.data.changes)
const changed = (path: string) => {
+ const node = store.node[path]
+ if (node?.status) return true
const set = changeset()
if (set.has(path)) return true
for (const p of set) {
@@ -138,24 +163,17 @@ function init() {
}
const resetNode = (path: string) => {
- setStore("node", path, {
- loaded: undefined,
- pinned: undefined,
- content: undefined,
- selection: undefined,
- scrollTop: undefined,
- folded: undefined,
- view: undefined,
- selectedChange: undefined,
- })
+ setStore("node", path, undefined!)
}
+ const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
+
const load = async (path: string) => {
- const relative = path.replace(sync.data.path.directory + "/", "")
- sdk.file.read({ query: { path: relative } }).then((x) => {
+ const relativePath = relative(path)
+ sdk.file.read({ query: { path: relativePath } }).then((x) => {
setStore(
"node",
- relative,
+ relativePath,
produce((draft) => {
draft.loaded = true
draft.content = x.data
@@ -164,28 +182,31 @@ function init() {
})
}
- const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
- const relative = path.replace(sync.data.path.directory + "/", "")
- if (!store.node[relative]) {
- const parent = relative.split("/").slice(0, -1).join("/")
- if (parent) {
- await list(parent)
- }
+ const fetch = async (path: string) => {
+ const relativePath = relative(path)
+ const parent = relativePath.split("/").slice(0, -1).join("/")
+ if (parent) {
+ await list(parent)
}
+ }
+
+ const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
+ const relativePath = relative(path)
+ if (!store.node[relativePath]) await fetch(path)
setStore("opened", (x) => {
- if (x.includes(relative)) return x
+ if (x.includes(relativePath)) return x
return [
...opened()
.filter((x) => x.pinned)
.map((x) => x.path),
- relative,
+ relativePath,
]
})
- setStore("active", relative)
+ setStore("active", relativePath)
if (options?.pinned) setStore("node", path, "pinned", true)
- if (options?.view && store.node[relative].view === undefined) setStore("node", path, "view", options.view)
- if (store.node[relative].loaded) return
- return load(relative)
+ if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
+ if (store.node[relativePath].loaded) return
+ return load(relativePath)
}
const list = async (path: string) => {
@@ -212,10 +233,9 @@ function init() {
if (part.type === "tool" && part.state.status === "completed") {
switch (part.tool) {
case "read":
- console.log("read", part.state.input)
break
case "edit":
- load(part.state.input["filePath"] as string)
+ // load(part.state.input["filePath"] as string)
break
default:
break
@@ -223,8 +243,10 @@ function init() {
}
break
case "file.watcher.updated":
- load(event.properties.file)
- sync.load.changes()
+ setTimeout(sync.load.changes, 1000)
+ const relativePath = relative(event.properties.file)
+ if (relativePath.startsWith(".git/")) return
+ load(relativePath)
break
}
})
@@ -298,9 +320,8 @@ function init() {
setChangeIndex(path: string, index: number | undefined) {
setStore("node", path, "selectedChange", index)
},
- changed,
changes,
- status,
+ changed,
children(path: string) {
return Object.values(store.node).filter(
(x) =>
@@ -310,6 +331,7 @@ function init() {
)
},
search,
+ relative,
}
})()
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index a03b8b58..79691967 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -1,6 +1,6 @@
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode } from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
-import { createContext, Show, useContext, type ParentProps } from "solid-js"
+import { createContext, createMemo, Show, useContext, type ParentProps } from "solid-js"
import { useSDK, useEvent } from "@/context"
import { Binary } from "@/utils/binary"
@@ -113,6 +113,9 @@ function init() {
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
+ const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
+ const sanitize = (text: string) => text.replace(sanitizer(), "")
+
return {
data: store,
set: setStore,
@@ -143,6 +146,7 @@ function init() {
},
},
load,
+ sanitize,
}
}
diff --git a/packages/app/src/pages/index.tsx b/packages/app/src/pages/index.tsx
index 6890522e..08c2a4a3 100644
--- a/packages/app/src/pages/index.tsx
+++ b/packages/app/src/pages/index.tsx
@@ -241,7 +241,7 @@ export default function Page() {
return (
@@ -261,7 +261,7 @@ export default function Page() {
No changes yet
}
+ fallback={
No changes
}
>
@@ -299,7 +299,7 @@ export default function Page() {
@@ -609,24 +609,21 @@ export default function Page() {
}
const TabVisual = (props: { file: LocalFile }) => {
- const local = useLocal()
return (
-
+
{props.file.name}
-
+
M
-
+
A
-
+
D
diff --git a/packages/app/src/ui/icon.tsx b/packages/app/src/ui/icon.tsx
index 0bbd17f5..b9ddcfbc 100644
--- a/packages/app/src/ui/icon.tsx
+++ b/packages/app/src/ui/icon.tsx
@@ -125,6 +125,11 @@ const icons = {
columns: '',
"open-pane": '',
"close-pane": '',
+ "file-search": '',
+ "folder-search": '',
+ search: '',
+ "web-search": '',
+ loading: '',
} as const
export function Icon(props: IconProps) {