1
0
Эх сурвалжийг харах

fix(tui): address inbox review findings

Ryan Vogel 2 долоо хоног өмнө
parent
commit
dbd934689f

+ 3 - 1
packages/tui/src/app.tsx

@@ -69,6 +69,7 @@ import { DialogSessionList } from "./component/dialog-session-list"
 import { DialogOpen } from "./component/dialog-open"
 import { SessionTabs } from "./component/session-tabs"
 import { SessionInbox } from "./component/session-inbox"
+import { SESSION_INBOX_MIN_TERMINAL_WIDTH } from "./context/session-tabs-model"
 import { ThemeErrorToast } from "./component/theme-error-toast"
 import { ThemeProvider, useTheme, useThemes } from "./context/theme"
 import { Home } from "./routes/home"
@@ -520,7 +521,8 @@ function App(props: { pair?: DialogPairCredentials }) {
   const terminalTitleEnabled = () => config.data.terminal?.title ?? true
   const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
   const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
-  const inboxTabsEnabled = () => config.data.tabs?.layout === "inbox" && dimensions().width >= 72
+  const inboxTabsEnabled = () =>
+    config.data.tabs?.layout === "inbox" && dimensions().width >= SESSION_INBOX_MIN_TERMINAL_WIDTH
 
   createEffect(() => {
     renderer.useMouse = config.data.mouse

+ 37 - 31
packages/tui/src/component/session-inbox.tsx

@@ -1,14 +1,14 @@
 import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
 import { useTerminalDimensions } from "@opentui/solid"
 import type { SessionMessageAssistant } from "@opencode-ai/client"
-import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
+import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
 import { useConfig } from "../config"
 import { useData } from "../context/data"
 import { Keymap } from "../context/keymap"
 import { usePromptRef } from "../context/prompt"
 import { useRoute } from "../context/route"
 import { useSessionTabs } from "../context/session-tabs"
-import { sessionInboxGroup, type SessionInboxGroup } from "../context/session-tabs-model"
+import { sessionInboxGroup, sessionInboxWidth, type SessionInboxGroup } from "../context/session-tabs-model"
 import { useTheme, useThemes } from "../context/theme"
 import { tint } from "../theme/color"
 import { getScrollAcceleration } from "../util/scroll"
@@ -101,40 +101,42 @@ export function SessionInboxRow(props: {
             >
               {props.row.title}
             </text>
-            <Show when={!props.row.status.busy && (props.row.status.unread || props.row.status.attention)}>
+            <Show when={props.row.status.attention || (!props.row.status.busy && props.row.status.unread)}>
               <text width={2} fg={feedback()} selectable={false}>
               </text>
             </Show>
           </box>
           <box height={1}>
-            <Show
-              when={!props.pendingDone && !props.row.status.busy}
+            <Switch
               fallback={
-                <Show
-                  when={props.pendingDone}
-                  fallback={
-                    <Spinner color={accent()}>
-                      <span style={{ fg: accent() }}>{props.verb ?? activityVerb(props.row.sessionID)}</span>
-                    </Spinner>
-                  }
+                <text
+                  height={1}
+                  fg={tint(theme.text.subdued, theme.text.default, props.selected ? 0.25 : 0)}
+                  wrapMode="none"
+                  truncate
+                  selectable={false}
                 >
-                  <text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
-                    Space again to mark done
-                  </text>
-                </Show>
+                  {props.row.preview}
+                </text>
               }
             >
-              <text
-                height={1}
-                fg={tint(theme.text.subdued, theme.text.default, props.selected ? 0.25 : 0)}
-                wrapMode="none"
-                truncate
-                selectable={false}
-              >
-                {props.row.preview}
-              </text>
-            </Show>
+              <Match when={props.pendingDone}>
+                <text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
+                  Space again to mark done
+                </text>
+              </Match>
+              <Match when={props.row.status.attention}>
+                <text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
+                  Action required
+                </text>
+              </Match>
+              <Match when={props.row.status.busy}>
+                <Spinner color={accent()}>
+                  <span style={{ fg: accent() }}>{props.verb ?? activityVerb(props.row.sessionID)}</span>
+                </Spinner>
+              </Match>
+            </Switch>
           </box>
         </box>
       </box>
@@ -158,7 +160,7 @@ export function SessionInbox() {
     clearInterval(verbTimer)
     tabs.navigation.blur()
   })
-  const width = createMemo(() => Math.max(28, Math.min(40, Math.floor(dimensions().width * 0.28))))
+  const width = createMemo(() => sessionInboxWidth(dimensions().width))
   const hueStep = () => (themes.mode() === "light" ? 800 : 200)
   const accent = () => theme.hue.accent[hueStep()]
   const rows = createMemo(() => {
@@ -178,13 +180,14 @@ export function SessionInbox() {
           .filter((part) => part.type === "text")
           .map((part) => part.text)
           .join("\n")
+        const updated = tabs.updated(tab.sessionID)
         return {
           sessionID: tab.sessionID,
           title: session ? withTimestampedFallback(session) : (tab.title ?? "Loading session…"),
-          updated: session?.time.updated ?? 0,
+          updated,
           preview: markdownPreview(preview ?? "") || "No assistant response yet",
           status,
-          group: sessionInboxGroup(session?.time.updated ?? 0, status.busy),
+          group: sessionInboxGroup(updated, status.busy),
         }
       })
       .toSorted((a, b) => b.updated - a.updated)
@@ -204,8 +207,11 @@ export function SessionInbox() {
     if (!row) return
     const top = scroll.scrollTop + row.y - scroll.viewport.y
     const bottom = top + row.height
-    if (top < scroll.scrollTop) scroll.scrollTo(top)
-    else if (bottom > scroll.scrollTop + scroll.viewport.height) scroll.scrollTo(bottom - scroll.viewport.height)
+    if (top < scroll.scrollTop) {
+      scroll.scrollTo(top)
+      return
+    }
+    if (bottom > scroll.scrollTop + scroll.viewport.height) scroll.scrollTo(bottom - scroll.viewport.height)
   })
 
   const leave = () => {

+ 1 - 1
packages/tui/src/config/v1/keybind.ts

@@ -85,7 +85,7 @@ export const Definitions = {
   session_export: keybind("<leader>x", "Export session to editor"),
   session_copy: keybind("none", "Copy session transcript"),
   session_move: keybind("none", "Move session"),
-  session_new: keybind("alt+t,ctrl+t,<leader>n", "Create a new session"),
+  session_new: keybind("alt+t,<leader>n", "Create a new session"),
   session_list: keybind("<leader>l", "List all sessions"),
   open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
   session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),

+ 6 - 0
packages/tui/src/context/session-tabs-model.ts

@@ -24,6 +24,12 @@ export const sessionTabOverflowWidth = (count: number) => String(count).length +
 
 export type SessionInboxGroup = "running" | "today" | "yesterday" | "earlier"
 
+export const SESSION_INBOX_MIN_TERMINAL_WIDTH = 72
+
+export function sessionInboxWidth(terminalWidth: number) {
+  return Math.max(28, Math.min(40, Math.floor(terminalWidth * 0.28)))
+}
+
 export function sessionInboxGroup(updated: number, running: boolean, now = Date.now()): SessionInboxGroup {
   if (running) return "running"
   const today = new Date(now)

+ 28 - 4
packages/tui/src/context/session-tabs.tsx

@@ -62,6 +62,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
     })
     const fallback = empty()
     const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
+    const [lastActivity, setLastActivity] = createSignal<Record<string, number>>({})
     const [navigationActive, setNavigationActive] = createSignal(false)
     const [navigationSelection, setNavigationSelection] = createSignal<string>()
     const [navigationPendingDone, setNavigationPendingDone] = createSignal<string>()
@@ -83,6 +84,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
     }
 
     const root = (sessionID: string) => data.session.root(sessionID)
+    const updated = (sessionID: string) =>
+      Math.max(data.session.get(sessionID)?.time.updated ?? 0, lastActivity()[root(sessionID)] ?? 0)
+    const touch = (sessionID: string, created: number) => {
+      const session = root(sessionID)
+      setLastActivity((value) => ({ ...value, [session]: Math.max(value[session] ?? 0, created) }))
+    }
     const title = (sessionID: string, persisted?: string, fallback?: string) => {
       const session = data.session.get(sessionID)
       return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
@@ -121,7 +128,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
     const recent = () =>
       orderSessionTabs(state().tabs, (sessionID) => ({
         busy: status(sessionID).busy,
-        updated: data.session.get(sessionID)?.time.updated ?? 0,
+        updated: updated(sessionID),
       }))
 
     function markUnread(sessionID: string, unread: SessionTabUnread) {
@@ -219,13 +226,29 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
       })
     })
 
-    onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
-    onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
-    onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
+    onCleanup(
+      event.on("session.execution.succeeded", (evt) => {
+        touch(evt.data.sessionID, evt.created)
+        markUnread(evt.data.sessionID, "activity")
+      }),
+    )
+    onCleanup(
+      event.on("session.execution.interrupted", (evt) => {
+        touch(evt.data.sessionID, evt.created)
+        markUnread(evt.data.sessionID, "activity")
+      }),
+    )
+    onCleanup(
+      event.on("session.execution.failed", (evt) => {
+        touch(evt.data.sessionID, evt.created)
+        markUnread(evt.data.sessionID, "error")
+      }),
+    )
     onCleanup(
       event.on("session.input.admitted", (evt) => {
         if (!enabled() || evt.data.input.type !== "user") return
         const sessionID = root(evt.data.sessionID)
+        touch(sessionID, evt.created)
         if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return
         setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 }))
       }),
@@ -272,6 +295,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
         return state().tabs
       },
       recent,
+      updated,
       newTab() {
         return newTab()
       },

+ 11 - 2
packages/tui/src/routes/session/index.tsx

@@ -73,6 +73,8 @@ import { DialogExportOptions } from "../../ui/dialog-export-options"
 import { DialogExportResult } from "../../ui/dialog-export-result"
 import { sessionEpilogue } from "../../util/presentation"
 import { useConfig } from "../../config"
+import { useSessionTabs } from "../../context/session-tabs"
+import { SESSION_INBOX_MIN_TERMINAL_WIDTH, sessionInboxWidth } from "../../context/session-tabs-model"
 import { useClipboard } from "../../context/clipboard"
 import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
 import { getScrollAcceleration } from "../../util/scroll"
@@ -190,6 +192,7 @@ export function Session() {
   })
 
   const dimensions = useTerminalDimensions()
+  const sessionTabs = useSessionTabs()
   const sidebar = createMemo(() => config.session?.sidebar ?? "auto")
   const [sidebarOpen, setSidebarOpen] = createSignal(false)
   const thinkingMode = createMemo<ThinkingMode>(() => config.session?.thinking ?? "hide")
@@ -199,14 +202,20 @@ export function Session() {
   const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
   const groupExploration = createMemo(() => config.session?.grouping !== "none")
 
-  const wide = createMemo(() => dimensions().width > 120)
+  const viewportWidth = createMemo(() => {
+    const width = dimensions().width
+    if (!sessionTabs.enabled() || config.tabs?.layout !== "inbox" || width < SESSION_INBOX_MIN_TERMINAL_WIDTH)
+      return width
+    return width - sessionInboxWidth(width)
+  })
+  const wide = createMemo(() => viewportWidth() > 120)
   const sidebarVisible = createMemo(() => {
     if (session()?.parentID) return false
     if (sidebarOpen()) return true
     if (sidebar() === "auto" && wide()) return true
     return false
   })
-  const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
+  const contentWidth = createMemo(() => viewportWidth() - (sidebarVisible() ? 42 : 0) - 4)
   const models = createMemo(() => data.location.model.list(location()) ?? [])
 
   const scrollAcceleration = createMemo(() => getScrollAcceleration(config))

+ 1 - 1
packages/tui/test/config.test.tsx

@@ -117,7 +117,7 @@ test("navigates session tabs with leader arrows", () => {
 test("preserves pinned session bindings alongside tab bindings", () => {
   const config = resolve({}, { terminalSuspend: true })
 
-  expect(config.keybinds.get("session.new")).toMatchObject([{ key: "alt+t,ctrl+t,<leader>n" }])
+  expect(config.keybinds.get("session.new")).toMatchObject([{ key: "alt+t,<leader>n" }])
   expect(config.keybinds.has("session.toggle.thinking")).toBe(false)
   expect(config.keybinds.get("session.pin.toggle")).toMatchObject([{ key: "ctrl+f" }])
   expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "<leader>1" }])

+ 7 - 0
packages/tui/test/context/session-tabs-model.test.ts

@@ -12,6 +12,7 @@ import {
   reopenSessionTab,
   seedSessionTabMotion,
   sessionInboxGroup,
+  sessionInboxWidth,
   sessionTabComplete,
   sessionTabOverflowWidth,
 } from "../../src/context/session-tabs-model"
@@ -42,6 +43,12 @@ describe("session tabs", () => {
     expect(sessionInboxGroup(new Date(2026, 6, 29, 23).getTime(), false, now)).toBe("earlier")
   })
 
+  test("sizes the inbox within its minimum and maximum rails", () => {
+    expect(sessionInboxWidth(72)).toBe(28)
+    expect(sessionInboxWidth(120)).toBe(33)
+    expect(sessionInboxWidth(200)).toBe(40)
+  })
+
   test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
     const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
     expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])

+ 31 - 0
packages/tui/test/context/session-tabs.test.tsx

@@ -218,6 +218,37 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
   }
 })
 
+test("tracks live inbox recency beyond cached session metadata", async () => {
+  const setup = await renderSessionTabs("background")
+
+  try {
+    expect(setup.tabs.updated("background")).toBe(0)
+    setup.emit({
+      id: "evt_admitted",
+      created: 100,
+      type: "session.input.admitted",
+      durable: { aggregateID: "background", seq: 1, version: 1 },
+      data: {
+        sessionID: "background",
+        inputID: "msg_1",
+        input: { type: "user", data: { text: "work" }, delivery: "steer" },
+      },
+    })
+    await wait(() => setup.tabs.updated("background") === 100)
+
+    setup.emit({
+      id: "evt_succeeded",
+      created: 200,
+      type: "session.execution.succeeded",
+      durable: { aggregateID: "background", seq: 2, version: 1 },
+      data: { sessionID: "background" },
+    })
+    await wait(() => setup.tabs.updated("background") === 200)
+  } finally {
+    setup.destroy()
+  }
+})
+
 test("tracks a temporary new session tab across close and creation", async () => {
   const setup = await renderSessionTabs("first")