Browse Source

feat(desktop): new layout

Adam 2 months ago
parent
commit
182630e0d7
2 changed files with 202 additions and 289 deletions
  1. 10 17
      packages/desktop/src/context/layout.tsx
  2. 192 272
      packages/desktop/src/pages/session.tsx

+ 10 - 17
packages/desktop/src/context/layout.tsx

@@ -46,8 +46,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           opened: false,
           height: 280,
         },
-        review: {
-          state: "pane" as "pane" | "tab",
+        session: {
+          width: 600,
         },
         sessionTabs: {} as Record<string, SessionTabs>,
       }),
@@ -156,13 +156,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           setStore("terminal", "height", height)
         },
       },
-      review: {
-        state: createMemo(() => store.review?.state ?? "closed"),
-        pane() {
-          setStore("review", "state", "pane")
-        },
-        tab() {
-          setStore("review", "state", "tab")
+      session: {
+        width: createMemo(() => store.session?.width ?? 600),
+        resize(width: number) {
+          if (!store.session) {
+            setStore("session", { width })
+          } else {
+            setStore("session", "width", width)
+          }
         },
       },
       tabs(sessionKey: string) {
@@ -186,14 +187,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
             }
           },
           async open(tab: string) {
-            if (tab === "chat") {
-              if (!store.sessionTabs[sessionKey]) {
-                setStore("sessionTabs", sessionKey, { all: [], active: undefined })
-              } else {
-                setStore("sessionTabs", sessionKey, "active", undefined)
-              }
-              return
-            }
             const current = store.sessionTabs[sessionKey] ?? { all: [] }
             if (tab !== "review") {
               if (!current.all.includes(tab)) {

+ 192 - 272
packages/desktop/src/pages/session.tsx

@@ -22,7 +22,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { DiffChanges } from "@opencode-ai/ui/diff-changes"
-import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { useCodeComponent } from "@opencode-ai/ui/context/code"
@@ -50,7 +49,7 @@ import { DialogSelectFile } from "@/components/dialog-select-file"
 import { DialogSelectModel } from "@/components/dialog-select-model"
 import { useCommand } from "@/context/command"
 import { useNavigate, useParams } from "@solidjs/router"
-import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
+import { UserMessage } from "@opencode-ai/sdk/v2"
 import { useSDK } from "@/context/sdk"
 import { usePrompt } from "@/context/prompt"
 import { extractPromptFromParts } from "@/utils/prompt"
@@ -118,27 +117,8 @@ export default function Page() {
     setActiveMessage(msgs[targetIndex])
   }
 
-  const last = createMemo(
-    () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
-  )
-  const model = createMemo(() =>
-    last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
-  )
   const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
 
-  const tokens = createMemo(() => {
-    if (!last()) return
-    const t = last().tokens
-    return t.input + t.output + t.reasoning + t.cache.read + t.cache.write
-  })
-
-  const context = createMemo(() => {
-    const total = tokens()
-    const limit = model()?.limit.context
-    if (!total || !limit) return 0
-    return Math.round((total / limit) * 100)
-  })
-
   const [store, setStore] = createStore({
     clickTimer: undefined as number | undefined,
     activeDraggable: undefined as string | undefined,
@@ -551,273 +531,213 @@ export default function Page() {
     )
   }
 
-  const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length)
+  const showTabs = createMemo(() => diffs().length > 0 || tabs().all().length > 0)
 
   return (
     <div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
-      <div class="min-h-0 grow w-full">
-        <DragDropProvider
-          onDragStart={handleDragStart}
-          onDragEnd={handleDragEnd}
-          onDragOver={handleDragOver}
-          collisionDetector={closestCenter}
+      <div class="min-h-0 grow w-full flex">
+        {/* Session pane - always visible */}
+        <div
+          class="relative shrink-0 py-3 flex flex-col gap-6 min-h-0 h-full bg-background-stronger"
+          style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
         >
-          <DragDropSensors />
-          <ConstrainDragYAxis />
-          <Tabs value={tabs().active() ?? "chat"} onChange={tabs().open}>
-            <div class="sticky top-0 shrink-0 flex">
-              <Tabs.List>
-                <Tabs.Trigger value="chat">
-                  <div class="flex gap-x-[17px] items-center">
-                    <div>Session</div>
-                    <Tooltip
-                      value={`${new Intl.NumberFormat("en-US", {
-                        notation: "compact",
-                        compactDisplay: "short",
-                      }).format(tokens() ?? 0)} Tokens`}
-                      class="flex items-center gap-1.5"
-                    >
-                      <ProgressCircle percentage={context() ?? 0} />
-                      <div class="text-14-regular text-text-weak text-left w-7">{context() ?? 0}%</div>
-                    </Tooltip>
+          <div class="flex-1 min-h-0 overflow-hidden">
+            <Switch>
+              <Match when={params.id}>
+                <div class="flex items-start justify-start h-full min-h-0">
+                  <SessionMessageRail
+                    messages={visibleUserMessages()}
+                    current={activeMessage()}
+                    onMessageSelect={setActiveMessage}
+                    wide={!showTabs()}
+                  />
+                  <Show when={activeMessage()}>
+                    <SessionTurn
+                      sessionID={params.id!}
+                      messageID={activeMessage()!.id}
+                      stepsExpanded={store.stepsExpanded}
+                      onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
+                      onUserInteracted={() => setStore("userInteracted", true)}
+                      classes={{
+                        root: "pb-20 flex-1 min-w-0",
+                        content: "pb-20",
+                        container:
+                          "w-full " +
+                          (!showTabs()
+                            ? "max-w-200 mx-auto px-6"
+                            : visibleUserMessages().length > 1
+                              ? "pr-6 pl-18"
+                              : "px-6"),
+                      }}
+                    />
+                  </Show>
+                </div>
+              </Match>
+              <Match when={true}>
+                <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
+                  <div class="text-20-medium text-text-weaker">New session</div>
+                  <div class="flex justify-center items-center gap-3">
+                    <Icon name="folder" size="small" />
+                    <div class="text-12-medium text-text-weak">
+                      {getDirectory(sync.data.path.directory)}
+                      <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
+                    </div>
                   </div>
-                </Tabs.Trigger>
-                <Show when={layout.review.state() === "tab" && diffs().length}>
-                  <Tabs.Trigger
-                    value="review"
-                    closeButton={
-                      <Tooltip value="Close tab" placement="bottom">
-                        <IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
-                      </Tooltip>
-                    }
-                  >
-                    <div class="flex items-center gap-3">
-                      <Show when={diffs()}>
-                        <DiffChanges changes={diffs()} variant="bars" />
-                      </Show>
-                      <div class="flex items-center gap-1.5">
-                        <div>Review</div>
-                        <Show when={info()?.summary?.files}>
-                          <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
-                            {info()?.summary?.files ?? 0}
-                          </div>
-                        </Show>
+                  <Show when={sync.project}>
+                    {(project) => (
+                      <div class="flex justify-center items-center gap-3">
+                        <Icon name="pencil-line" size="small" />
+                        <div class="text-12-medium text-text-weak">
+                          Last modified&nbsp;
+                          <span class="text-text-strong">
+                            {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
+                          </span>
+                        </div>
                       </div>
-                    </div>
-                  </Tabs.Trigger>
-                </Show>
-                <SortableProvider ids={tabs().all() ?? []}>
-                  <For each={tabs().all() ?? []}>
-                    {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
-                  </For>
-                </SortableProvider>
-                <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
-                  <Tooltip value="Open file" class="flex items-center">
-                    <IconButton
-                      icon="plus-small"
-                      variant="ghost"
-                      iconSize="large"
-                      onClick={() => dialog.show(() => <DialogSelectFile />)}
-                    />
-                  </Tooltip>
+                    )}
+                  </Show>
                 </div>
-              </Tabs.List>
+              </Match>
+            </Switch>
+          </div>
+          <div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
+            <div class="w-full max-w-200 px-6">
+              <PromptInput
+                ref={(el) => {
+                  inputRef = el
+                }}
+              />
             </div>
-            <Tabs.Content
-              value="chat"
-              class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden contain-strict"
+          </div>
+          <Show when={showTabs()}>
+            <ResizeHandle
+              direction="horizontal"
+              size={layout.session.width()}
+              min={450}
+              max={window.innerWidth * 0.45}
+              onResize={layout.session.resize}
+            />
+          </Show>
+        </div>
+
+        {/* Tabs pane - visible when there are diffs or file tabs */}
+        <Show when={showTabs()}>
+          <div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
+            <DragDropProvider
+              onDragStart={handleDragStart}
+              onDragEnd={handleDragEnd}
+              onDragOver={handleDragOver}
+              collisionDetector={closestCenter}
             >
-              <div
-                classList={{
-                  "w-full flex-1 min-h-0": true,
-                  grid: layout.review.state() === "tab",
-                  flex: layout.review.state() === "pane",
-                }}
-              >
-                <div
-                  classList={{
-                    "relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full": true,
-                    "max-w-146 mx-auto": !wide(),
-                  }}
-                >
-                  <Switch>
-                    <Match when={params.id}>
-                      <div class="flex items-start justify-start h-full min-h-0">
-                        <SessionMessageRail
-                          messages={visibleUserMessages()}
-                          current={activeMessage()}
-                          onMessageSelect={setActiveMessage}
-                          wide={wide()}
-                        />
-                        <Show when={activeMessage()}>
-                          <SessionTurn
-                            sessionID={params.id!}
-                            messageID={activeMessage()!.id}
-                            stepsExpanded={store.stepsExpanded}
-                            onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
-                            onUserInteracted={() => setStore("userInteracted", true)}
-                            classes={{
-                              root: "pb-20 flex-1 min-w-0",
-                              content: "pb-20",
-                              container:
-                                "w-full " +
-                                (wide()
-                                  ? "max-w-200 mx-auto px-6"
-                                  : visibleUserMessages().length > 1
-                                    ? "pr-6 pl-18"
-                                    : "px-6"),
-                            }}
-                          />
-                        </Show>
-                      </div>
-                    </Match>
-                    <Match when={true}>
-                      <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
-                        <div class="text-20-medium text-text-weaker">New session</div>
-                        <div class="flex justify-center items-center gap-3">
-                          <Icon name="folder" size="small" />
-                          <div class="text-12-medium text-text-weak">
-                            {getDirectory(sync.data.path.directory)}
-                            <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
+              <DragDropSensors />
+              <ConstrainDragYAxis />
+              <Tabs value={tabs().active() ?? "review"} onChange={tabs().open}>
+                <div class="sticky top-0 shrink-0 flex">
+                  <Tabs.List>
+                    <Show when={diffs().length}>
+                      <Tabs.Trigger value="review">
+                        <div class="flex items-center gap-3">
+                          <Show when={diffs()}>
+                            <DiffChanges changes={diffs()} variant="bars" />
+                          </Show>
+                          <div class="flex items-center gap-1.5">
+                            <div>Review</div>
+                            <Show when={info()?.summary?.files}>
+                              <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
+                                {info()?.summary?.files ?? 0}
+                              </div>
+                            </Show>
                           </div>
                         </div>
-                        <Show when={sync.project}>
-                          {(project) => (
-                            <div class="flex justify-center items-center gap-3">
-                              <Icon name="pencil-line" size="small" />
-                              <div class="text-12-medium text-text-weak">
-                                Last modified&nbsp;
-                                <span class="text-text-strong">
-                                  {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
-                                </span>
-                              </div>
-                            </div>
-                          )}
-                        </Show>
-                      </div>
-                    </Match>
-                  </Switch>
-                  <div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
-                    <div class="w-full max-w-200 px-6">
-                      <PromptInput
-                        ref={(el) => {
-                          inputRef = el
+                      </Tabs.Trigger>
+                    </Show>
+                    <SortableProvider ids={tabs().all() ?? []}>
+                      <For each={tabs().all() ?? []}>
+                        {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
+                      </For>
+                    </SortableProvider>
+                    <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
+                      <Tooltip value="Open file" class="flex items-center">
+                        <IconButton
+                          icon="plus-small"
+                          variant="ghost"
+                          iconSize="large"
+                          onClick={() => dialog.show(() => <DialogSelectFile />)}
+                        />
+                      </Tooltip>
+                    </div>
+                  </Tabs.List>
+                </div>
+                <Show when={diffs().length}>
+                  <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
+                    <div class="relative pt-3 flex-1 min-h-0 overflow-hidden">
+                      <SessionReview
+                        classes={{
+                          root: "pb-40",
+                          header: "px-6",
+                          container: "px-6",
                         }}
+                        diffs={diffs()}
+                        split
                       />
                     </div>
-                  </div>
-                </div>
-                <Show when={layout.review.state() === "pane" && diffs().length}>
-                  <div
-                    classList={{
-                      "relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base contain-strict": true,
-                    }}
-                  >
-                    <SessionReview
-                      classes={{
-                        root: "pb-20",
-                        header: "px-6",
-                        container: "px-6",
-                      }}
-                      diffs={diffs()}
-                      actions={
-                        <Tooltip value="Open in tab">
-                          <IconButton
-                            icon="expand"
-                            variant="ghost"
-                            onClick={() => {
-                              layout.review.tab()
-                              tabs().setActive("review")
-                            }}
-                          />
-                        </Tooltip>
-                      }
-                    />
-                  </div>
+                  </Tabs.Content>
                 </Show>
-              </div>
-            </Tabs.Content>
-            <Show when={layout.review.state() === "tab" && diffs().length}>
-              <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
-                <div
-                  classList={{
-                    "relative pt-3 flex-1 min-h-0 overflow-hidden": true,
+                <For each={tabs().all()}>
+                  {(tab) => {
+                    const [file] = createResource(
+                      () => tab,
+                      async (tab) => {
+                        if (tab.startsWith("file://")) {
+                          return local.file.node(tab.replace("file://", ""))
+                        }
+                        return undefined
+                      },
+                    )
+                    return (
+                      <Tabs.Content value={tab} class="select-text mt-3">
+                        <Switch>
+                          <Match when={file()}>
+                            {(f) => (
+                              <Dynamic
+                                component={codeComponent}
+                                file={{
+                                  name: f().path,
+                                  contents: f().content?.content ?? "",
+                                  cacheKey: checksum(f().content?.content ?? ""),
+                                }}
+                                overflow="scroll"
+                                class="pb-40"
+                              />
+                            )}
+                          </Match>
+                        </Switch>
+                      </Tabs.Content>
+                    )
                   }}
-                >
-                  <SessionReview
-                    classes={{
-                      root: "pb-40",
-                      header: "px-6",
-                      container: "px-6",
-                    }}
-                    diffs={diffs()}
-                    split
-                  />
-                </div>
-              </Tabs.Content>
-            </Show>
-            <For each={tabs().all()}>
-              {(tab) => {
-                const [file] = createResource(
-                  () => tab,
-                  async (tab) => {
-                    if (tab.startsWith("file://")) {
-                      return local.file.node(tab.replace("file://", ""))
-                    }
-                    return undefined
-                  },
-                )
-                return (
-                  <Tabs.Content value={tab} class="select-text mt-3">
-                    <Switch>
-                      <Match when={file()}>
-                        {(f) => (
-                          <Dynamic
-                            component={codeComponent}
-                            file={{
-                              name: f().path,
-                              contents: f().content?.content ?? "",
-                              cacheKey: checksum(f().content?.content ?? ""),
-                            }}
-                            overflow="scroll"
-                            class="pb-40"
-                          />
-                        )}
-                      </Match>
-                    </Switch>
-                  </Tabs.Content>
-                )
-              }}
-            </For>
-          </Tabs>
-          <DragOverlay>
-            <Show when={store.activeDraggable}>
-              {(draggedFile) => {
-                const [file] = createResource(
-                  () => draggedFile(),
-                  async (tab) => {
-                    if (tab.startsWith("file://")) {
-                      return local.file.node(tab.replace("file://", ""))
-                    }
-                    return undefined
-                  },
-                )
-                return (
-                  <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
-                    <Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
-                  </div>
-                )
-              }}
-            </Show>
-          </DragOverlay>
-        </DragDropProvider>
-        <Show when={tabs().active()}>
-          <div class="absolute inset-x-0 px-6 max-w-200 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
-            <PromptInput
-              ref={(el) => {
-                inputRef = el
-              }}
-            />
+                </For>
+              </Tabs>
+              <DragOverlay>
+                <Show when={store.activeDraggable}>
+                  {(draggedFile) => {
+                    const [file] = createResource(
+                      () => draggedFile(),
+                      async (tab) => {
+                        if (tab.startsWith("file://")) {
+                          return local.file.node(tab.replace("file://", ""))
+                        }
+                        return undefined
+                      },
+                    )
+                    return (
+                      <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
+                        <Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
+                      </div>
+                    )
+                  }}
+                </Show>
+              </DragOverlay>
+            </DragDropProvider>
           </div>
         </Show>
       </div>