Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions packages/tui/src/context/session-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useStorage } from "./storage"
import { useTuiPaths } from "./runtime"
import { newSessionLocation } from "../config/new-session-location"
import { createSessionRetention } from "./session-retention"
import { anchorKey, type AnchorTarget } from "../routes/session/anchors"
import {
closeSessionTab,
cycleSessionTab,
Expand All @@ -40,8 +41,8 @@ type PersistedState = {
cwd: Record<string, TabsState>
}

type ScrollAnchor = {
messageID: string
export type ScrollAnchor = {
target: AnchorTarget
screenY: number
}

Expand Down Expand Up @@ -90,6 +91,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
// the mark.
const cancelledTabs = new Set<string>()
const scrollAnchors = new Map<string, ScrollAnchor>()
const [expandedGroups, setExpandedGroups] = createStore<Record<string, Record<string, boolean> | undefined>>({})

const onFocus = () => setFocused(true)
const onBlur = () => setFocused(false)
Expand Down Expand Up @@ -202,7 +204,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (state().tabs.some((tab) => tab.sessionID === sessionID)) return
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
const replaced = permanent ? undefined : previewID()
if (replaced) family(replaced).forEach((id) => scrollAnchors.delete(id))
if (replaced)
family(replaced).forEach((id) => {
scrollAnchors.delete(id)
setExpandedGroups(id, undefined)
})
if (!permanent) setPreview(sessionID)
update((draft) => {
if (cancelledTabs.has(sessionID)) return
Expand Down Expand Up @@ -346,7 +352,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID)
cancelledTabs.add(target)
family(target).forEach((id) => scrollAnchors.delete(id))
family(target).forEach((id) => {
scrollAnchors.delete(id)
setExpandedGroups(id, undefined)
})
if (previewID() === target) setPreview(undefined)
const closed = closeSessionTab(state().tabs, target)
const selected = navigate && current() === target
Expand Down Expand Up @@ -393,9 +402,16 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
return
}
const current = scrollAnchors.get(sessionID)
if (current?.messageID === anchor.messageID && current.screenY === anchor.screenY) return
if (current && anchorKey(current.target) === anchorKey(anchor.target) && current.screenY === anchor.screenY)
return
scrollAnchors.set(sessionID, anchor)
},
groupExpanded(sessionID: string, groupID: string) {
return expandedGroups[sessionID]?.[groupID]
},
setGroupExpanded(sessionID: string, groupID: string, expanded: boolean) {
setExpandedGroups(sessionID, (current) => ({ ...current, [groupID]: expanded }))
},
select(sessionID: string) {
if (!enabled()) return
route.navigate({ type: "session", sessionID: root(sessionID) })
Expand Down
53 changes: 53 additions & 0 deletions packages/tui/src/routes/session/anchor-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
import type { BoxRenderable } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import type { SessionEntry, SessionNode } from "./grouping/session"
import { entryRef } from "./anchors"
import { use } from "./render-context"

export function visitEntries(nodes: readonly SessionNode[], visit: (entry: SessionEntry) => void) {
nodes.forEach((node) => {
if (node.type === "entry") visit(node.entry)
if (node.type === "group") visitEntries(node.children, visit)
})
}

export function useEntryAnchor(props: {
entry: Accessor<SessionEntry | undefined>
node: Accessor<BoxRenderable | undefined>
}) {
const ctx = use()
createEffect(() => {
const entry = props.entry()
const node = props.node()
const ref = entry && entryRef(entry)
if (!ref || !node) return
onCleanup(ctx.anchors.register({ target: { type: "part", ref }, node }))
})
}

export function EntryAnchor(props: { entry: SessionEntry; children: JSX.Element; marginTop?: number }) {
const [node, setNode] = createSignal<BoxRenderable>()
useEntryAnchor({ entry: () => props.entry, node })
return (
<box ref={setNode} marginTop={props.marginTop} flexShrink={0}>
{props.children}
</box>
)
}

export function GroupAnchor(props: { groupID: string | undefined; active: boolean; children: JSX.Element }) {
const ctx = use()
const [node, setNode] = createSignal<BoxRenderable>()
createEffect(() => {
const target = node()
const groupID = props.groupID
if (!target || !groupID || !props.active) return
onCleanup(ctx.anchors.register({ target: { type: "group", groupID }, node: target }))
})
return (
<box ref={setNode} flexDirection="column" flexShrink={0}>
{props.children}
</box>
)
}
85 changes: 85 additions & 0 deletions packages/tui/src/routes/session/anchors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { Renderable } from "@opentui/core"
import type { PartRef, SessionEntry, SessionNode } from "./grouping/session"

export type AnchorTarget = { type: "part"; ref: PartRef } | { type: "group"; groupID: string }

type Anchor = {
target: AnchorTarget
node: Pick<Renderable, "y" | "height" | "isDestroyed">
}

export function anchorKey(target: AnchorTarget) {
return target.type === "part"
? JSON.stringify(["part", target.ref.messageID, target.ref.partID])
: JSON.stringify(["group", target.groupID])
}

/** Whole non-assistant messages have one canonical UI body part. Derived
* footer/usage rows use the preceding entry as their scroll reference. */
export function entryRef(entry: SessionEntry): PartRef | undefined {
// Saved identities must not follow an in-place Solid store reconciliation.
if (entry.type === "part") return { messageID: entry.ref.messageID, partID: entry.ref.partID }
if (entry.type === "message") return { messageID: entry.messageID, partID: "message" }
}

export function groupID(node: Extract<SessionNode, { type: "group" }>, level: number) {
const ref = firstRef(node.children)
return ref && JSON.stringify([ref.messageID, ref.partID, node.kind, level])
}

function firstRef(nodes: readonly SessionNode[]): PartRef | undefined {
for (const node of nodes) {
const ref = node.type === "entry" ? entryRef(node.entry) : firstRef(node.children)
if (ref) return ref
}
}

export function containsAnchor(
node: SessionEntry | Extract<SessionNode, { type: "group" }>,
target: AnchorTarget,
level = 0,
): boolean {
if (node.type === "group") {
if (target.type === "group" && groupID(node, level) === target.groupID) return true
return node.children.some((child) =>
containsAnchor(child.type === "entry" ? child.entry : child, target, level + 1),
)
}
const ref = entryRef(node)
return target.type === "part" && ref?.messageID === target.ref.messageID && ref.partID === target.ref.partID
}

/** Only mounted parts and actual group headers register. Geometry stays in OpenTUI. */
export function createTimelineAnchors() {
const entries = new Map<string, Anchor>()
const list = () =>
[...entries.values()]
.filter((anchor) => !anchor.node.isDestroyed && anchor.node.height > 0)
.sort((a, b) => a.node.y - b.node.y)
return {
register(anchor: Anchor) {
const key = anchorKey(anchor.target)
entries.set(key, anchor)
return () => {
if (entries.get(key) === anchor) entries.delete(key)
}
},
get(target: AnchorTarget) {
const anchor = entries.get(anchorKey(target))
return anchor && !anchor.node.isDestroyed && anchor.node.height > 0 ? anchor : undefined
},
forMessage(messageID: string) {
return list().find((anchor) => anchor.target.type === "part" && anchor.target.ref.messageID === messageID)
},
messagePositions() {
const seen = new Set<string>()
return list().flatMap((anchor) => {
if (anchor.target.type !== "part" || seen.has(anchor.target.ref.messageID)) return []
const id = anchor.target.ref.messageID
seen.add(id)
return [{ id, y: anchor.node.y }]
})
},
list,
}
}
Loading
Loading