Explorar o código

wip: better desktop file status state and timeline

Adam hai 5 meses
pai
achega
b207ed2b7b

+ 1 - 1
packages/app/src/components/code.tsx

@@ -435,7 +435,7 @@ function transformerUnifiedDiff(): ShikiTransformer {
         out.push(s)
         out.push(s)
       }
       }
 
 
-      return out.join("\n")
+      return out.join("\n").trimEnd()
     },
     },
     code(node) {
     code(node) {
       if (isDiff) this.addClassToHast(node, "code-diff")
       if (isDiff) this.addClassToHast(node, "code-diff")

+ 1 - 2
packages/app/src/components/markdown.tsx

@@ -6,8 +6,7 @@ function strip(text: string): string {
   const match = text.match(wrappedRe)
   const match = text.match(wrappedRe)
   return match ? match[2] : text
   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 marked = useMarked()
   const [html] = createResource(
   const [html] = createResource(
     () => strip(props.text),
     () => strip(props.text),

+ 213 - 111
packages/app/src/components/session-timeline.tsx

@@ -1,5 +1,5 @@
 import { useLocal, useSync } from "@/context"
 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 type { Part, ToolPart } from "@opencode-ai/sdk"
 import { DateTime } from "luxon"
 import { DateTime } from "luxon"
 import {
 import {
@@ -13,58 +13,14 @@ import {
   type ParentProps,
   type ParentProps,
   createEffect,
   createEffect,
   createMemo,
   createMemo,
+  Show,
 } from "solid-js"
 } from "solid-js"
 import { getFilename } from "@/utils"
 import { getFilename } from "@/utils"
-import Markdown from "./markdown"
+import { Markdown } from "./markdown"
 import { Code } from "./code"
 import { Code } from "./code"
 import { createElementSize } from "@solid-primitives/resize-observer"
 import { createElementSize } from "@solid-primitives/resize-observer"
 import { createScrollPosition } from "@solid-primitives/scroll"
 import { createScrollPosition } from "@solid-primitives/scroll"
 
 
-function TimelineIcon(props: { name: IconProps["name"]; class?: string }) {
-  return (
-    <div
-      classList={{
-        "relative flex flex-none self-start items-center justify-center bg-background h-6 w-6": true,
-        [props.class ?? ""]: !!props.class,
-      }}
-    >
-      <Icon name={props.name} class="text-text/40" size={18} />
-    </div>
-  )
-}
-
-function CollapsibleTimelineIcon(props: { name: IconProps["name"]; class?: string }) {
-  return (
-    <>
-      <TimelineIcon
-        name={props.name}
-        class={`group-hover/li:hidden group-has-[[data-expanded]]/li:hidden ${props.class ?? ""}`}
-      />
-      <TimelineIcon
-        name="chevron-right"
-        class={`hidden group-hover/li:flex group-has-[[data-expanded]]/li:hidden ${props.class ?? ""}`}
-      />
-      <TimelineIcon name="chevron-down" class={`hidden group-has-[[data-expanded]]/li:flex ${props.class ?? ""}`} />
-    </>
-  )
-}
-
-function ToolIcon(props: { part: ToolPart }) {
-  return (
-    <Switch fallback={<TimelineIcon name="hammer" />}>
-      <Match when={props.part.tool === "read"}>
-        <TimelineIcon name="file" />
-      </Match>
-      <Match when={props.part.tool === "edit"}>
-        <CollapsibleTimelineIcon name="pencil" />
-      </Match>
-      <Match when={props.part.tool === "write"}>
-        <CollapsibleTimelineIcon name="file-plus" />
-      </Match>
-    </Switch>
-  )
-}
-
 function Part(props: ParentProps & ComponentProps<"div">) {
 function Part(props: ParentProps & ComponentProps<"div">) {
   const [local, others] = splitProps(props, ["class", "classList", "children"])
   const [local, others] = splitProps(props, ["class", "classList", "children"])
   return (
   return (
@@ -97,9 +53,13 @@ function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps
 }
 }
 
 
 function ReadToolPart(props: { part: ToolPart }) {
 function ReadToolPart(props: { part: ToolPart }) {
+  const sync = useSync()
   const local = useLocal()
   const local = useLocal()
   return (
   return (
     <Switch>
     <Switch>
+      <Match when={props.part.state.status === "pending"}>
+        <Part>Reading file...</Part>
+      </Match>
       <Match when={props.part.state.status === "completed" && props.part.state}>
       <Match when={props.part.state.status === "completed" && props.part.state}>
         {(state) => {
         {(state) => {
           const path = state().input["filePath"] as string
           const path = state().input["filePath"] as string
@@ -110,13 +70,27 @@ function ReadToolPart(props: { part: ToolPart }) {
           )
           )
         }}
         }}
       </Match>
       </Match>
+      <Match when={props.part.state.status === "error" && props.part.state}>
+        {(state) => (
+          <div>
+            <Part>
+              <span class="text-text-muted">Read</span> {getFilename(state().input["filePath"] as string)}
+            </Part>
+            <div class="text-error">{sync.sanitize(state().error)}</div>
+          </div>
+        )}
+      </Match>
     </Switch>
     </Switch>
   )
   )
 }
 }
 
 
 function EditToolPart(props: { part: ToolPart }) {
 function EditToolPart(props: { part: ToolPart }) {
+  const sync = useSync()
   return (
   return (
     <Switch>
     <Switch>
+      <Match when={props.part.state.status === "pending"}>
+        <Part>Preparing edit...</Part>
+      </Match>
       <Match when={props.part.state.status === "completed" && props.part.state}>
       <Match when={props.part.state.status === "completed" && props.part.state}>
         {(state) => (
         {(state) => (
           <CollapsiblePart
           <CollapsiblePart
@@ -135,13 +109,30 @@ function EditToolPart(props: { part: ToolPart }) {
           </CollapsiblePart>
           </CollapsiblePart>
         )}
         )}
       </Match>
       </Match>
+      <Match when={props.part.state.status === "error" && props.part.state}>
+        {(state) => (
+          <CollapsiblePart
+            title={
+              <>
+                <span class="text-text-muted">Edit</span> {getFilename(state().input["filePath"] as string)}
+              </>
+            }
+          >
+            <div class="text-error">{sync.sanitize(state().error)}</div>
+          </CollapsiblePart>
+        )}
+      </Match>
     </Switch>
     </Switch>
   )
   )
 }
 }
 
 
 function WriteToolPart(props: { part: ToolPart }) {
 function WriteToolPart(props: { part: ToolPart }) {
+  const sync = useSync()
   return (
   return (
     <Switch>
     <Switch>
+      <Match when={props.part.state.status === "pending"}>
+        <Part>Preparing write...</Part>
+      </Match>
       <Match when={props.part.state.status === "completed" && props.part.state}>
       <Match when={props.part.state.status === "completed" && props.part.state}>
         {(state) => (
         {(state) => (
           <CollapsiblePart
           <CollapsiblePart
@@ -155,38 +146,98 @@ function WriteToolPart(props: { part: ToolPart }) {
           </CollapsiblePart>
           </CollapsiblePart>
         )}
         )}
       </Match>
       </Match>
+      <Match when={props.part.state.status === "error" && props.part.state}>
+        {(state) => (
+          <div>
+            <Part>
+              <span class="text-text-muted">Write</span> {getFilename(state().input["filePath"] as string)}
+            </Part>
+            <div class="text-error">{sync.sanitize(state().error)}</div>
+          </div>
+        )}
+      </Match>
     </Switch>
     </Switch>
   )
   )
 }
 }
 
 
-function ToolPart(props: { part: ToolPart }) {
+function BashToolPart(props: { part: ToolPart }) {
+  const sync = useSync()
   return (
   return (
-    <Switch
-      fallback={
-        <div class="flex-auto min-w-0 text-xs">
-          {props.part.type}:{props.part.tool}
-        </div>
-      }
-    >
-      <Match when={props.part.tool === "read"}>
-        <div class="min-w-0 flex-auto">
-          <ReadToolPart part={props.part} />
-        </div>
+    <Switch>
+      <Match when={props.part.state.status === "pending"}>
+        <Part>Writing shell command...</Part>
       </Match>
       </Match>
-      <Match when={props.part.tool === "edit"}>
-        <div class="min-w-0 flex-auto">
-          <EditToolPart part={props.part} />
-        </div>
+      <Match when={props.part.state.status === "completed" && props.part.state}>
+        {(state) => (
+          <CollapsiblePart
+            defaultOpen
+            title={
+              <>
+                <span class="text-text-muted">Run command:</span> {state().input["command"]}
+              </>
+            }
+          >
+            <Markdown text={`\`\`\`command\n${state().input["command"]}\n${state().output}\`\`\``} />
+          </CollapsiblePart>
+        )}
       </Match>
       </Match>
-      <Match when={props.part.tool === "write"}>
-        <div class="min-w-0 flex-auto">
-          <WriteToolPart part={props.part} />
-        </div>
+      <Match when={props.part.state.status === "error" && props.part.state}>
+        {(state) => (
+          <CollapsiblePart
+            title={
+              <>
+                <span class="text-text-muted">Shell</span> {state().input["command"]}
+              </>
+            }
+          >
+            <div class="text-error">{sync.sanitize(state().error)}</div>
+          </CollapsiblePart>
+        )}
       </Match>
       </Match>
     </Switch>
     </Switch>
   )
   )
 }
 }
 
 
+function ToolPart(props: { part: ToolPart }) {
+  // read
+  // edit
+  // write
+  // bash
+  // ls
+  // glob
+  // grep
+  // todowrite
+  // todoread
+  // webfetch
+  // websearch
+  // patch
+  // task
+  return (
+    <div class="min-w-0 flex-auto text-xs">
+      <Switch
+        fallback={
+          <span>
+            {props.part.type}:{props.part.tool}
+          </span>
+        }
+      >
+        <Match when={props.part.tool === "read"}>
+          <ReadToolPart part={props.part} />
+        </Match>
+        <Match when={props.part.tool === "edit"}>
+          <EditToolPart part={props.part} />
+        </Match>
+        <Match when={props.part.tool === "write"}>
+          <WriteToolPart part={props.part} />
+        </Match>
+        <Match when={props.part.tool === "bash"}>
+          <BashToolPart part={props.part} />
+        </Match>
+      </Switch>
+    </div>
+  )
+}
+
 export default function SessionTimeline(props: { session: string; class?: string }) {
 export default function SessionTimeline(props: { session: string; class?: string }) {
   const sync = useSync()
   const sync = useSync()
   const [scrollElement, setScrollElement] = createSignal<HTMLElement | undefined>(undefined)
   const [scrollElement, setScrollElement] = createSignal<HTMLElement | undefined>(undefined)
@@ -196,6 +247,7 @@ export default function SessionTimeline(props: { session: string; class?: string
   const scroll = createScrollPosition(scrollElement)
   const scroll = createScrollPosition(scrollElement)
 
 
   onMount(() => sync.session.sync(props.session))
   onMount(() => sync.session.sync(props.session))
+  const session = createMemo(() => sync.session.get(props.session))
   const messages = createMemo(() => sync.data.message[props.session] ?? [])
   const messages = createMemo(() => sync.data.message[props.session] ?? [])
   const working = createMemo(() => {
   const working = createMemo(() => {
     const last = messages()[messages().length - 1]
     const last = messages()[messages().length - 1]
@@ -285,60 +337,33 @@ export default function SessionTimeline(props: { session: string; class?: string
     <div
     <div
       ref={setRoot}
       ref={setRoot}
       classList={{
       classList={{
-        "p-4 select-text flex flex-col gap-y-8": true,
+        "p-4 select-text flex flex-col gap-y-1": true,
         [props.class ?? ""]: !!props.class,
         [props.class ?? ""]: !!props.class,
       }}
       }}
     >
     >
-      <For each={messages()}>
-        {(message) => (
-          <ul role="list" class="space-y-2">
+      <ul role="list" class="flex flex-col gap-1">
+        <For each={messages()}>
+          {(message) => (
             <For each={sync.data.part[message.id]?.filter(valid)}>
             <For each={sync.data.part[message.id]?.filter(valid)}>
               {(part) => (
               {(part) => (
-                <li classList={{ "relative group/li flex gap-x-4 min-w-0 w-full": true }}>
-                  <div
-                    classList={{
-                      "absolute top-0 left-0 flex w-6 justify-center": true,
-                      "last:h-10 not-last:-bottom-10": true,
-                    }}
-                  >
-                    <div class="w-px bg-border-subtle" />
-                  </div>
-                  <Switch
-                    fallback={
-                      <div class="m-0.5 relative flex size-5 flex-none items-center justify-center bg-background">
-                        <div class="size-1 rounded-full bg-text/10 ring ring-text/20" />
-                      </div>
-                    }
-                  >
-                    <Match when={part.type === "text"}>
-                      <Switch>
-                        <Match when={message.role === "user"}>
-                          <TimelineIcon name="avatar-square" />
-                        </Match>
-                        <Match when={message.role === "assistant"}>
-                          <TimelineIcon name="sparkles" />
-                        </Match>
-                      </Switch>
-                    </Match>
-                    <Match when={part.type === "reasoning"}>
-                      <CollapsibleTimelineIcon name="brain" />
-                    </Match>
-                    <Match when={part.type === "tool" && part}>{(part) => <ToolIcon part={part()} />}</Match>
-                  </Switch>
+                <li class="group/li">
                   <Switch fallback={<div class="flex-auto min-w-0 text-xs mt-1 text-left">{part.type}</div>}>
                   <Switch fallback={<div class="flex-auto min-w-0 text-xs mt-1 text-left">{part.type}</div>}>
                     <Match when={part.type === "text" && part}>
                     <Match when={part.type === "text" && part}>
                       {(part) => (
                       {(part) => (
                         <Switch>
                         <Switch>
                           <Match when={message.role === "user"}>
                           <Match when={message.role === "user"}>
-                            <div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0">
+                            <div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0 mt-5 group-first/li:mt-0">
                               <p class="w-full rounded-md p-3 ring-1 ring-text/15 ring-inset text-xs bg-background-panel">
                               <p class="w-full rounded-md p-3 ring-1 ring-text/15 ring-inset text-xs bg-background-panel">
                                 <span class="font-medium text-text whitespace-pre-wrap break-words">{part().text}</span>
                                 <span class="font-medium text-text whitespace-pre-wrap break-words">{part().text}</span>
                               </p>
                               </p>
-                              <p class="text-xs text-text-muted">12:07pm · adam</p>
+                              <p class="text-xs text-text-muted">
+                                {DateTime.fromMillis(message.time.created).toRelative()} ·{" "}
+                                {sync.data.config.username ?? "user"}
+                              </p>
                             </div>
                             </div>
                           </Match>
                           </Match>
                           <Match when={message.role === "assistant"}>
                           <Match when={message.role === "assistant"}>
-                            <Markdown text={part().text} class="text-text" />
+                            <Markdown text={sync.sanitize(part().text)} class="text-text mt-1" />
                           </Match>
                           </Match>
                         </Switch>
                         </Switch>
                       )}
                       )}
@@ -347,9 +372,11 @@ export default function SessionTimeline(props: { session: string; class?: string
                       {(part) => (
                       {(part) => (
                         <CollapsiblePart
                         <CollapsiblePart
                           title={
                           title={
-                            <>
-                              <span class="text-text-muted">Thought</span> for {duration(part())}s
-                            </>
+                            <Switch fallback={<span class="text-text-muted">Thinking</span>}>
+                              <Match when={part().time.end}>
+                                <span class="text-text-muted">Thought</span> for {duration(part())}s
+                              </Match>
+                            </Switch>
                           }
                           }
                         >
                         >
                           <Markdown text={part().text} />
                           <Markdown text={part().text} />
@@ -361,9 +388,84 @@ export default function SessionTimeline(props: { session: string; class?: string
                 </li>
                 </li>
               )}
               )}
             </For>
             </For>
-          </ul>
-        )}
-      </For>
+          )}
+        </For>
+      </ul>
+      <Show when={false}>
+        <Collapsible defaultOpen={false}>
+          <Collapsible.Trigger>
+            <div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
+              <Icon name="file-code" size={16} />
+              <span>Raw Session Data</span>
+              <Collapsible.Arrow size={18} class="text-text-muted" />
+            </div>
+          </Collapsible.Trigger>
+          <Collapsible.Content class="mt-5">
+            <ul role="list" class="space-y-2">
+              <li>
+                <Collapsible>
+                  <Collapsible.Trigger>
+                    <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
+                      <Icon name="file-code" size={16} />
+                      <span>session</span>
+                      <Collapsible.Arrow size={18} class="text-text-muted" />
+                    </div>
+                  </Collapsible.Trigger>
+                  <Collapsible.Content>
+                    <Code path="session.json" code={JSON.stringify(session(), null, 2)} class="[&_code]:pb-0!" />
+                  </Collapsible.Content>
+                </Collapsible>
+              </li>
+              <For each={messages()}>
+                {(message) => (
+                  <>
+                    <li>
+                      <Collapsible>
+                        <Collapsible.Trigger>
+                          <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
+                            <Icon name="file-code" size={16} />
+                            <span>{message.role === "user" ? "user" : "assistant"}</span>
+                            <Collapsible.Arrow size={18} class="text-text-muted" />
+                          </div>
+                        </Collapsible.Trigger>
+                        <Collapsible.Content>
+                          <Code
+                            path={message.id + ".json"}
+                            code={JSON.stringify(message, null, 2)}
+                            class="[&_code]:pb-0!"
+                          />
+                        </Collapsible.Content>
+                      </Collapsible>
+                    </li>
+                    <For each={sync.data.part[message.id]?.filter(valid)}>
+                      {(part) => (
+                        <li>
+                          <Collapsible>
+                            <Collapsible.Trigger>
+                              <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
+                                <Icon name="file-code" size={16} />
+                                <span>{part.type}</span>
+                                <Collapsible.Arrow size={18} class="text-text-muted" />
+                              </div>
+                            </Collapsible.Trigger>
+                            <Collapsible.Content>
+                              <Code
+                                path={message.id + "." + part.id + ".json"}
+                                code={JSON.stringify(part, null, 2)}
+                                class="[&_code]:pb-0!"
+                              />
+                            </Collapsible.Content>
+                          </Collapsible>
+                        </li>
+                      )}
+                    </For>
+                  </>
+                )}
+              </For>
+            </ul>
+          </Collapsible.Content>
+        </Collapsible>
+      </Show>
     </div>
     </div>
   )
   )
 }
 }

+ 56 - 34
packages/app/src/context/local.tsx

@@ -1,7 +1,7 @@
 import { createStore, produce, reconcile } from "solid-js/store"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
 import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
 import { uniqueBy } from "remeda"
 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"
 import { useSDK, useEvent, useSync } from "@/context"
 
 
 export type LocalFile = FileNode &
 export type LocalFile = FileNode &
@@ -15,6 +15,7 @@ export type LocalFile = FileNode &
     view: "raw" | "diff-unified" | "diff-split"
     view: "raw" | "diff-unified" | "diff-split"
     folded: string[]
     folded: string[]
     selectedChange: number
     selectedChange: number
+    status: FileStatus
   }>
   }>
 export type TextSelection = LocalFile["selection"]
 export type TextSelection = LocalFile["selection"]
 export type View = LocalFile["view"]
 export type View = LocalFile["view"]
@@ -126,9 +127,33 @@ function init() {
     const opened = createMemo(() => store.opened.map((x) => store.node[x]))
     const opened = createMemo(() => store.opened.map((x) => store.node[x]))
     const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
     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 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 changed = (path: string) => {
+      const node = store.node[path]
+      if (node?.status) return true
       const set = changeset()
       const set = changeset()
       if (set.has(path)) return true
       if (set.has(path)) return true
       for (const p of set) {
       for (const p of set) {
@@ -138,24 +163,17 @@ function init() {
     }
     }
 
 
     const resetNode = (path: string) => {
     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 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(
         setStore(
           "node",
           "node",
-          relative,
+          relativePath,
           produce((draft) => {
           produce((draft) => {
             draft.loaded = true
             draft.loaded = true
             draft.content = x.data
             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) => {
       setStore("opened", (x) => {
-        if (x.includes(relative)) return x
+        if (x.includes(relativePath)) return x
         return [
         return [
           ...opened()
           ...opened()
             .filter((x) => x.pinned)
             .filter((x) => x.pinned)
             .map((x) => x.path),
             .map((x) => x.path),
-          relative,
+          relativePath,
         ]
         ]
       })
       })
-      setStore("active", relative)
+      setStore("active", relativePath)
       if (options?.pinned) setStore("node", path, "pinned", true)
       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) => {
     const list = async (path: string) => {
@@ -212,10 +233,9 @@ function init() {
           if (part.type === "tool" && part.state.status === "completed") {
           if (part.type === "tool" && part.state.status === "completed") {
             switch (part.tool) {
             switch (part.tool) {
               case "read":
               case "read":
-                console.log("read", part.state.input)
                 break
                 break
               case "edit":
               case "edit":
-                load(part.state.input["filePath"] as string)
+                // load(part.state.input["filePath"] as string)
                 break
                 break
               default:
               default:
                 break
                 break
@@ -223,8 +243,10 @@ function init() {
           }
           }
           break
           break
         case "file.watcher.updated":
         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
           break
       }
       }
     })
     })
@@ -298,9 +320,8 @@ function init() {
       setChangeIndex(path: string, index: number | undefined) {
       setChangeIndex(path: string, index: number | undefined) {
         setStore("node", path, "selectedChange", index)
         setStore("node", path, "selectedChange", index)
       },
       },
-      changed,
       changes,
       changes,
-      status,
+      changed,
       children(path: string) {
       children(path: string) {
         return Object.values(store.node).filter(
         return Object.values(store.node).filter(
           (x) =>
           (x) =>
@@ -310,6 +331,7 @@ function init() {
         )
         )
       },
       },
       search,
       search,
+      relative,
     }
     }
   })()
   })()
 
 

+ 5 - 1
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 type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode } from "@opencode-ai/sdk"
 import { createStore, produce, reconcile } from "solid-js/store"
 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 { useSDK, useEvent } from "@/context"
 import { Binary } from "@/utils/binary"
 import { Binary } from "@/utils/binary"
 
 
@@ -113,6 +113,9 @@ function init() {
 
 
   Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
   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 {
   return {
     data: store,
     data: store,
     set: setStore,
     set: setStore,
@@ -143,6 +146,7 @@ function init() {
       },
       },
     },
     },
     load,
     load,
+    sanitize,
   }
   }
 }
 }
 
 

+ 7 - 10
packages/app/src/pages/index.tsx

@@ -241,7 +241,7 @@ export default function Page() {
   return (
   return (
     <div class="relative">
     <div class="relative">
       <div
       <div
-        class="fixed top-0 left-0 h-full border-r border-border-subtle/30 flex flex-col overflow-hidden"
+        class="fixed top-0 left-0 h-full border-r border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10"
         style={`width: ${local.layout.leftWidth()}px`}
         style={`width: ${local.layout.leftWidth()}px`}
       >
       >
         <Tabs class="relative flex flex-col h-full" defaultValue="files">
         <Tabs class="relative flex flex-col h-full" defaultValue="files">
@@ -261,7 +261,7 @@ export default function Page() {
           <Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
           <Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
             <Show
             <Show
               when={local.file.changes().length}
               when={local.file.changes().length}
-              fallback={<div class="px-2 text-xs text-text-muted">No changes yet</div>}
+              fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
             >
             >
               <ul class="">
               <ul class="">
                 <For each={local.file.changes()}>
                 <For each={local.file.changes()}>
@@ -299,7 +299,7 @@ export default function Page() {
       </div>
       </div>
       <Show when={local.layout.rightPane()}>
       <Show when={local.layout.rightPane()}>
         <div
         <div
-          class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden"
+          class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10"
           style={`width: ${local.layout.rightWidth()}px`}
           style={`width: ${local.layout.rightWidth()}px`}
         >
         >
           <div class="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
           <div class="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
@@ -609,24 +609,21 @@ export default function Page() {
 }
 }
 
 
 const TabVisual = (props: { file: LocalFile }) => {
 const TabVisual = (props: { file: LocalFile }) => {
-  const local = useLocal()
   return (
   return (
     <div class="flex items-center gap-x-1.5">
     <div class="flex items-center gap-x-1.5">
       <FileIcon node={props.file} class="" />
       <FileIcon node={props.file} class="" />
-      <span
-        classList={{ "text-xs": true, "text-primary": local.file.changed(props.file.path), italic: !props.file.pinned }}
-      >
+      <span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}>
         {props.file.name}
         {props.file.name}
       </span>
       </span>
       <span class="text-xs opacity-70">
       <span class="text-xs opacity-70">
         <Switch>
         <Switch>
-          <Match when={local.file.status(props.file.path)?.status === "modified"}>
+          <Match when={props.file.status?.status === "modified"}>
             <span class="text-primary">M</span>
             <span class="text-primary">M</span>
           </Match>
           </Match>
-          <Match when={local.file.status(props.file.path)?.status === "added"}>
+          <Match when={props.file.status?.status === "added"}>
             <span class="text-success">A</span>
             <span class="text-success">A</span>
           </Match>
           </Match>
-          <Match when={local.file.status(props.file.path)?.status === "deleted"}>
+          <Match when={props.file.status?.status === "deleted"}>
             <span class="text-error">D</span>
             <span class="text-error">D</span>
           </Match>
           </Match>
         </Switch>
         </Switch>

+ 5 - 0
packages/app/src/ui/icon.tsx

@@ -125,6 +125,11 @@ const icons = {
   columns: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.75 6.75C4.75 5.64543 5.64543 4.75 6.75 4.75H17.25C18.3546 4.75 19.25 5.64543 19.25 6.75V17.25C19.25 18.3546 18.3546 19.25 17.25 19.25H6.75C5.64543 19.25 4.75 18.3546 4.75 17.25V6.75Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 5V19"></path>',
   columns: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.75 6.75C4.75 5.64543 5.64543 4.75 6.75 4.75H17.25C18.3546 4.75 19.25 5.64543 19.25 6.75V17.25C19.25 18.3546 18.3546 19.25 17.25 19.25H6.75C5.64543 19.25 4.75 18.3546 4.75 17.25V6.75Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 5V19"></path>',
     "open-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.25 4.75v14.5m-3-8.5L9.75 12l1.5 1.25m-4.5 6h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
     "open-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.25 4.75v14.5m-3-8.5L9.75 12l1.5 1.25m-4.5 6h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
     "close-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 4.75v14.5m3-8.5L14.25 12l-1.5 1.25M6.75 19.25h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
     "close-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 4.75v14.5m3-8.5L14.25 12l-1.5 1.25M6.75 19.25h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
+   "file-search": '<path fill="currentColor" d="M17.25 9.25V10a.75.75 0 0 0 .53-1.28l-.53.53Zm-4.5-4.5.53-.53a.75.75 0 0 0-.53-.22v.75ZM10.25 20a.75.75 0 0 0 0-1.5V20Zm7.427-3.383a.75.75 0 0 0-1.06 1.06l1.06-1.06Zm1.043 3.163a.75.75 0 1 0 1.06-1.06l-1.06 1.06Zm-.94-11.06-4.5-4.5-1.06 1.06 4.5 4.5 1.06-1.06ZM12.75 4h-6v1.5h6V4ZM4 6.75v10.5h1.5V6.75H4ZM6.75 20h3.5v-1.5h-3.5V20ZM12 4.75v3.5h1.5v-3.5H12ZM13.75 10h3.5V8.5h-3.5V10ZM12 8.25c0 .966.784 1.75 1.75 1.75V8.5a.25.25 0 0 1-.25-.25H12Zm-8 9A2.75 2.75 0 0 0 6.75 20v-1.5c-.69 0-1.25-.56-1.25-1.25H4ZM6.75 4A2.75 2.75 0 0 0 4 6.75h1.5c0-.69.56-1.25 1.25-1.25V4Zm8.485 14.47a3.235 3.235 0 0 0 3.236-3.235h-1.5c0 .959-.777 1.736-1.736 1.736v1.5Zm0-4.97c.959 0 1.736.777 1.736 1.735h1.5A3.235 3.235 0 0 0 15.235 12v1.5Zm0-1.5A3.235 3.235 0 0 0 12 15.235h1.5c0-.958.777-1.735 1.735-1.735V12Zm0 4.97a1.735 1.735 0 0 1-1.735-1.735H12a3.235 3.235 0 0 0 3.235 3.236v-1.5Zm1.382.707 2.103 2.103 1.06-1.06-2.103-2.103-1.06 1.06Z"></path>',
+   "folder-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.25 19.25h-3.5a2 2 0 0 1-2-2v-9.5h12.5a2 2 0 0 1 2 2v.5m-5.75-2.5-.931-1.958a2 2 0 0 0-1.756-1.042H6.75a2 2 0 0 0-2 2V11m12.695 6.445 1.805 1.805m-3.75-1a2.75 2.75 0 1 0 0-5.5 2.75 2.75 0 0 0 0 5.5Z"></path>',
+   search: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 19.25L15.5 15.5M4.75 11C4.75 7.54822 7.54822 4.75 11 4.75C14.4518 4.75 17.25 7.54822 17.25 11C17.25 14.4518 14.4518 17.25 11 17.25C7.54822 17.25 4.75 14.4518 4.75 11Z"></path>',
+   "web-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 8.25v-.5a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v.5m14.5 0H4.75m14.5 0v2m-14.5-2v8a2 2 0 0 0 2 2h2.5m7.743-1.257 2.257 2.257m-4.015-1.53a2.485 2.485 0 1 0 0-4.97 2.485 2.485 0 0 0 0 4.97Z"></path>',
+   loading: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.75v1.5m5.126.624L16 8m3.25 4h-1.5m-.624 5.126-1.768-1.768M12 16.75v2.5m-3.36-3.891-1.768 1.768M7.25 12h-2.5m3.891-3.358L6.874 6.874"></path>',
  } as const
  } as const
 
 
 export function Icon(props: IconProps) {
 export function Icon(props: IconProps) {