Kaynağa Gözat

feat(app): sessions list improvements (#30941)

Aarav Sareen 2 ay önce
ebeveyn
işleme
1fd9c77744

+ 13 - 0
packages/app/src/context/tabs.tsx

@@ -1,4 +1,6 @@
+import type { Session } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
+import { base64Encode } from "@opencode-ai/core/util/encode"
 import { createStore, produce } from "solid-js/store"
 import { Persist, persisted } from "@/utils/persist"
 import { ServerConnection, useServer } from "./server"
@@ -18,6 +20,17 @@ export type Tab = SessionTab
 export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
 export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}`
 
+export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
+  const dirBase64 = base64Encode(session.directory)
+  return tabs.some(
+    (tab) =>
+      tab.type === "session" &&
+      tab.server === server &&
+      tab.dirBase64 === dirBase64 &&
+      tab.sessionId === session.id,
+  )
+}
+
 export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
   name: "Tabs",
   gate: false,

+ 61 - 126
packages/app/src/pages/home.tsx

@@ -11,7 +11,6 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
-import { TabStateIndicator } from "@opencode-ai/ui/v2/tab-state-indicator"
 import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
 import { useNavigate } from "@solidjs/router"
 import { base64Encode } from "@opencode-ai/core/util/encode"
@@ -23,26 +22,24 @@ import { DialogSelectDirectory } from "@/components/dialog-select-directory"
 import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
 import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
 import { ServerConnection, useServer } from "@/context/server"
+import { sessionHasOpenTab, useTabs } from "@/context/tabs"
 import { useServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { useNotification } from "@/context/notification"
-import { usePermission } from "@/context/permission"
 import {
   closeHomeProject,
   displayName,
   getProjectAvatarSource,
   homeProjectDirectories,
   homeProjectNavigation,
-  homeSessionServerStatus,
   type HomeProjectSelection,
   projectForSession,
   sortedRootSessions,
   toggleHomeProjectSelection,
 } from "@/pages/layout/helpers"
+import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
 import { sessionTitle } from "@/utils/session-title"
 import { pathKey } from "@/utils/path-key"
-import { messageAgentColor } from "@/utils/agent"
-import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
 import { useGlobal } from "@/context/global"
 import { useCommand } from "@/context/command"
 import { useSettings } from "@/context/settings"
@@ -65,8 +62,6 @@ type HomeSessionRecord = {
   projectName: string
 }
 
-type HomeSessionSync = Pick<ReturnType<typeof useServerSync>, "child">
-
 type HomeSessionGroup = {
   id: "today" | "yesterday" | "older"
   title: string
@@ -113,53 +108,6 @@ function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
   return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
 }
 
-function createHomeSessionStatus(input: {
-  record: () => HomeSessionRecord
-  sync: () => HomeSessionSync
-  activeServer: () => boolean
-}) {
-  const notification = useNotification()
-  const permission = usePermission()
-  const sessionStore = createMemo(() => input.sync().child(input.record().session.directory, { bootstrap: false })[0])
-  const unseenCount = createMemo(() =>
-    input.activeServer() ? notification.session.unseenCount(input.record().session.id) : 0,
-  )
-  const hasError = createMemo(
-    () => input.activeServer() && notification.session.unseenHasError(input.record().session.id),
-  )
-  const hasPermissions = createMemo(
-    () =>
-      input.activeServer() &&
-      !!sessionPermissionRequest(
-        sessionStore().session,
-        sessionStore().permission,
-        input.record().session.id,
-        (item) => {
-          return !permission.autoResponds(item, input.record().session.directory)
-        },
-      ),
-  )
-  const serverStatus = createMemo(() =>
-    homeSessionServerStatus(input.activeServer(), () => ({
-      working: sessionStore().session_working(input.record().session.id),
-      tint: messageAgentColor(sessionStore().message[input.record().session.id], sessionStore().agent),
-    })),
-  )
-  const isWorking = createMemo(() => {
-    if (hasPermissions()) return false
-    return serverStatus().working
-  })
-  const tint = createMemo(() => serverStatus().tint)
-  return {
-    unseenCount,
-    hasError,
-    hasPermissions,
-    isWorking,
-    tint,
-    show: createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0),
-  }
-}
-
 function homeSessionSearchKey(record: HomeSessionRecord) {
   return `${pathKey(record.session.directory)}:${record.session.id}`
 }
@@ -424,7 +372,7 @@ function HomeDesign() {
             open={searchOpen()}
             loading={sessionLoad.isLoading}
             results={searchResults()}
-            sync={focusedSync()}
+            server={state.selection.server}
             activeServer={state.selection.server === server.key}
             noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
             bindFocus={(focus) => {
@@ -464,7 +412,7 @@ function HomeDesign() {
                             {(record) => (
                               <HomeSessionRow
                                 record={record}
-                                sync={focusedSync()}
+                                server={state.selection.server}
                                 activeServer={state.selection.server === server.key}
                                 openSession={openSession}
                               />
@@ -748,13 +696,54 @@ function HomeProjectAvatar(props: { project: LocalProject }) {
   )
 }
 
+function HomeSessionAvatar(props: { project: LocalProject; session: Session; activeServer: boolean }) {
+  const directory = () => props.session.directory
+  const sessionId = () => props.session.id
+  const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
+  return (
+    <ProjectAvatar
+      fallback={displayName(props.project)}
+      src={getProjectAvatarSource(props.project.id, props.project.icon)}
+      variant={getProjectAvatarVariant(props.project.icon?.color)}
+      unread={state.unread()}
+      loading={state.loading()}
+    />
+  )
+}
+
+function HomeSessionLeading(props: {
+  project: LocalProject
+  session: Session
+  server: ServerConnection.Key
+  activeServer: boolean
+}) {
+  const tabs = useTabs()
+  const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
+  return (
+    <div class="relative shrink-0">
+      <Show when={hasOpenTab()}>
+        <span
+          aria-hidden="true"
+          class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
+          style={{ right: "calc(100% + 12px)" }}
+        />
+      </Show>
+      <HomeSessionAvatar
+        project={props.project}
+        session={props.session}
+        activeServer={props.activeServer}
+      />
+    </div>
+  )
+}
+
 function HomeSessionSearch(props: {
   value: string
   placeholder: string
   open: boolean
   loading: boolean
   results: HomeSessionRecord[]
-  sync: HomeSessionSync
+  server: ServerConnection.Key
   activeServer: boolean
   noResultsLabel: string
   bindFocus: (focus: () => void) => void
@@ -870,7 +859,7 @@ function HomeSessionSearch(props: {
                           {(record) => (
                             <HomeSessionSearchResultRow
                               record={record}
-                              sync={props.sync}
+                              server={props.server}
                               activeServer={props.activeServer}
                               selected={store.active === homeSessionSearchKey(record)}
                               onHighlight={() => setStore("active", homeSessionSearchKey(record))}
@@ -956,17 +945,12 @@ function HomeSessionSearch(props: {
 
 function HomeSessionSearchResultRow(props: {
   record: HomeSessionRecord
-  sync: HomeSessionSync
+  server: ServerConnection.Key
   activeServer: boolean
   selected: boolean
   onHighlight: () => void
   onSelect: (session: Session) => void
 }) {
-  const status = createHomeSessionStatus({
-    record: () => props.record,
-    sync: () => props.sync,
-    activeServer: () => props.activeServer,
-  })
   const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
 
   const key = () => homeSessionSearchKey(props.record)
@@ -986,34 +970,12 @@ function HomeSessionSearchResultRow(props: {
       onMouseEnter={() => props.onHighlight()}
       onClick={() => props.onSelect(props.record.session)}
     >
-      <Show
-        when={status.show()}
-        fallback={
-          <div class="flex size-4 shrink-0 items-center justify-center">
-            <TabStateIndicator />
-          </div>
-        }
-      >
-        <div
-          class="flex size-4 shrink-0 items-center justify-center"
-          style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
-        >
-          <Switch>
-            <Match when={status.isWorking()}>
-              <Spinner class="size-[15px]" />
-            </Match>
-            <Match when={status.hasPermissions()}>
-              <div class="size-1.5 rounded-full bg-surface-warning-strong" />
-            </Match>
-            <Match when={status.hasError()}>
-              <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
-            </Match>
-            <Match when={status.unseenCount() > 0}>
-              <div class="size-1.5 rounded-full bg-text-interactive-base" />
-            </Match>
-          </Switch>
-        </div>
-      </Show>
+      <HomeSessionLeading
+        project={props.record.project}
+        session={props.record.session}
+        server={props.server}
+        activeServer={props.activeServer}
+      />
       <div class="flex min-w-0 flex-1 items-center gap-1.5">
         <span
           class={`${HOME_SEARCH_RESULT_TITLE} ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
@@ -1053,15 +1015,10 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
 
 function HomeSessionRow(props: {
   record: HomeSessionRecord
-  sync: HomeSessionSync
+  server: ServerConnection.Key
   activeServer: boolean
   openSession: (session: Session) => void
 }) {
-  const status = createHomeSessionStatus({
-    record: () => props.record,
-    sync: () => props.sync,
-    activeServer: () => props.activeServer,
-  })
   const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
 
   return (
@@ -1071,34 +1028,12 @@ function HomeSessionRow(props: {
       class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
       onClick={() => props.openSession(props.record.session)}
     >
-      <Show
-        when={status.show()}
-        fallback={
-          <div class="flex size-4 shrink-0 items-center justify-center">
-            <TabStateIndicator />
-          </div>
-        }
-      >
-        <div
-          class="flex size-4 shrink-0 items-center justify-center"
-          style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
-        >
-          <Switch>
-            <Match when={status.isWorking()}>
-              <Spinner class="size-[15px]" />
-            </Match>
-            <Match when={status.hasPermissions()}>
-              <div class="size-1.5 rounded-full bg-surface-warning-strong" />
-            </Match>
-            <Match when={status.hasError()}>
-              <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
-            </Match>
-            <Match when={status.unseenCount() > 0}>
-              <div class="size-1.5 rounded-full bg-text-interactive-base" />
-            </Match>
-          </Switch>
-        </div>
-      </Show>
+      <HomeSessionLeading
+        project={props.record.project}
+        session={props.record.session}
+        server={props.server}
+        activeServer={props.activeServer}
+      />
       <span
         class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
       >

+ 1 - 0
packages/ui/script/colors.txt

@@ -233,6 +233,7 @@
 --v2-background-bg-layer-01: var(--v2-grey-200);
 --v2-background-bg-layer-02: var(--v2-grey-300);
 --v2-background-bg-layer-03: var(--v2-grey-400);
+--v2-background-bg-layer-04: var(--v2-grey-600);
 --v2-background-bg-inverse: var(--v2-grey-1000);
 --v2-background-bg-contrast: var(--v2-grey-900);
 --v2-background-bg-button-neutral: var(--v2-grey-100);

+ 2 - 1
packages/ui/src/styles/tailwind/colors.css

@@ -237,6 +237,7 @@
   --color-v2-background-bg-layer-01: var(--v2-background-bg-layer-01);
   --color-v2-background-bg-layer-02: var(--v2-background-bg-layer-02);
   --color-v2-background-bg-layer-03: var(--v2-background-bg-layer-03);
+  --color-v2-background-bg-layer-04: var(--v2-background-bg-layer-04);
   --color-v2-background-bg-inverse: var(--v2-background-bg-inverse);
   --color-v2-background-bg-contrast: var(--v2-background-bg-contrast);
   --color-v2-background-bg-button-neutral: var(--v2-background-bg-button-neutral);
@@ -281,4 +282,4 @@
   --color-v2-state-bg-info: var(--v2-state-bg-info);
   --color-v2-state-fg-info: var(--v2-state-fg-info);
   --color-v2-state-border-info: var(--v2-state-border-info);
-}
+}

+ 2 - 0
packages/ui/src/theme/themes/oc-2.json

@@ -157,6 +157,7 @@
       "v2-background-bg-layer-01": "var(--v2-grey-200)",
       "v2-background-bg-layer-02": "var(--v2-grey-300)",
       "v2-background-bg-layer-03": "var(--v2-grey-400)",
+      "v2-background-bg-layer-04": "var(--v2-grey-400)",
       "v2-background-bg-inverse": "var(--v2-grey-1000)",
       "v2-background-bg-contrast": "var(--v2-grey-900)",
       "v2-background-bg-button-neutral": "var(--v2-grey-100)",
@@ -386,6 +387,7 @@
       "v2-background-bg-layer-01": "var(--v2-grey-900)",
       "v2-background-bg-layer-02": "var(--v2-grey-800)",
       "v2-background-bg-layer-03": "var(--v2-grey-700)",
+      "v2-background-bg-layer-04": "var(--v2-grey-700)",
       "v2-background-bg-inverse": "var(--v2-grey-100)",
       "v2-background-bg-contrast": "var(--v2-grey-700)",
       "v2-background-bg-button-neutral": "var(--v2-alpha-light-6)",

+ 2 - 0
packages/ui/src/theme/v2/mapping.ts

@@ -9,6 +9,7 @@ const light: Record<string, V2ColorValue> = {
   "v2-background-bg-layer-01": ref("v2-grey-300"),
   "v2-background-bg-layer-02": ref("v2-grey-400"),
   "v2-background-bg-layer-03": ref("v2-grey-500"),
+  "v2-background-bg-layer-04": ref("v2-grey-600"),
   "v2-background-bg-inverse": ref("v2-grey-1000"),
   "v2-background-bg-contrast": ref("v2-grey-900"),
   "v2-background-bg-button-neutral": ref("v2-grey-100"),
@@ -77,6 +78,7 @@ const dark: Record<string, V2ColorValue> = {
   "v2-background-bg-layer-01": ref("v2-grey-800"),
   "v2-background-bg-layer-02": ref("v2-grey-600"),
   "v2-background-bg-layer-03": ref("v2-grey-500"),
+  "v2-background-bg-layer-04": ref("v2-grey-400"),
   "v2-background-bg-inverse": ref("v2-grey-100"),
   "v2-background-bg-contrast": ref("v2-grey-700"),
   "v2-background-bg-button-neutral": ref("v2-alpha-light-6"),

+ 3 - 0
packages/ui/src/v2/styles/theme.css

@@ -8,6 +8,7 @@
     --v2-background-bg-layer-01: var(--v2-grey-200);
     --v2-background-bg-layer-02: var(--v2-grey-300);
     --v2-background-bg-layer-03: var(--v2-grey-400);
+    --v2-background-bg-layer-04: var(--v2-grey-600);
     --v2-background-bg-inverse: var(--v2-grey-1000);
     --v2-background-bg-contrast: var(--v2-grey-900);
     --v2-background-bg-button-neutral: var(--v2-grey-100);
@@ -229,6 +230,7 @@
     --v2-background-bg-layer-01: var(--v2-grey-200);
     --v2-background-bg-layer-02: var(--v2-grey-300);
     --v2-background-bg-layer-03: var(--v2-grey-400);
+    --v2-background-bg-layer-04: var(--v2-grey-600);
     --v2-background-bg-inverse: var(--v2-grey-1000);
     --v2-background-bg-contrast: var(--v2-grey-900);
     --v2-background-bg-button-neutral: var(--v2-grey-100);
@@ -337,6 +339,7 @@
     --v2-background-bg-layer-01: var(--v2-grey-900);
     --v2-background-bg-layer-02: var(--v2-grey-800);
     --v2-background-bg-layer-03: var(--v2-grey-700);
+    --v2-background-bg-layer-04: var(--v2-grey-600);
     --v2-background-bg-inverse: var(--v2-grey-100);
     --v2-background-bg-contrast: var(--v2-grey-700);
     --v2-background-bg-button-neutral: var(--v2-alpha-light-6);