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) {