mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-16 05:34:44 +00:00
7
bun.lock
7
bun.lock
@@ -483,11 +483,8 @@
|
|||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/bounds": "0.1.3",
|
"@solid-primitives/bounds": "0.1.3",
|
||||||
"@solid-primitives/lifecycle": "0.1.2",
|
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/page-visibility": "2.1.1",
|
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/rootless": "1.5.2",
|
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
@@ -1837,14 +1834,10 @@
|
|||||||
|
|
||||||
"@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="],
|
"@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="],
|
||||||
|
|
||||||
"@solid-primitives/lifecycle": ["@solid-primitives/lifecycle@0.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-+K0T10kZXqorocFj0coIqt8NYm2UqoZfpF3nm2RwrDMZMV+C+SC0Oi3N6Dnq2i7W/n1cHAnfpoV4CBLsW21lJw=="],
|
|
||||||
|
|
||||||
"@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="],
|
"@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="],
|
||||||
|
|
||||||
"@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
|
"@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
|
||||||
|
|
||||||
"@solid-primitives/page-visibility": ["@solid-primitives/page-visibility@2.1.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.1", "@solid-primitives/rootless": "^1.5.1", "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-CV9BqMqhunf4OOyBkhJCH9f5ivg0ADavdcaBsrqoFvwIk1FoD/blPSHYM4CK8IjS/AEXNcsjlNVc34lMu+2Wdg=="],
|
|
||||||
|
|
||||||
"@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="],
|
"@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="],
|
||||||
|
|
||||||
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="],
|
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="],
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
|||||||
import {
|
import {
|
||||||
dropdownMenuTriggerSelector,
|
dropdownMenuTriggerSelector,
|
||||||
dropdownMenuContentSelector,
|
dropdownMenuContentSelector,
|
||||||
sessionTimelineHeaderSelector,
|
|
||||||
projectMenuTriggerSelector,
|
projectMenuTriggerSelector,
|
||||||
projectCloseMenuSelector,
|
projectCloseMenuSelector,
|
||||||
projectWorkspacesToggleSelector,
|
projectWorkspacesToggleSelector,
|
||||||
@@ -244,9 +243,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
|||||||
|
|
||||||
const scroller = page.locator(".scroll-view__viewport").first()
|
const scroller = page.locator(".scroll-view__viewport").first()
|
||||||
await expect(scroller).toBeVisible()
|
await expect(scroller).toBeVisible()
|
||||||
const header = page.locator(sessionTimelineHeaderSelector).first()
|
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
await expect(header).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const menu = page
|
const menu = page
|
||||||
.locator(dropdownMenuContentSelector)
|
.locator(dropdownMenuContentSelector)
|
||||||
@@ -262,7 +259,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
|||||||
|
|
||||||
if (opened) return menu
|
if (opened) return menu
|
||||||
|
|
||||||
const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
|
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||||
await expect(menuTrigger).toBeVisible()
|
await expect(menuTrigger).toBeVisible()
|
||||||
await menuTrigger.click()
|
await menuTrigger.click()
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
|
|||||||
|
|
||||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||||
|
|
||||||
export const sessionTimelineHeaderSelector = "[data-session-title]"
|
|
||||||
|
|
||||||
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
||||||
|
|
||||||
export const workspaceItemSelector = (slug: string) =>
|
export const workspaceItemSelector = (slug: string) =>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
openSharePopover,
|
openSharePopover,
|
||||||
withSession,
|
withSession,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { sessionItemSelector, inlineInputSelector, sessionTimelineHeaderSelector } from "../selectors"
|
import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||||
|
|
||||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||||
|
|
||||||
@@ -39,14 +39,12 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
|
|||||||
await withSession(sdk, originalTitle, async (session) => {
|
await withSession(sdk, originalTitle, async (session) => {
|
||||||
await seedMessage(sdk, session.id)
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||||
originalTitle,
|
|
||||||
)
|
|
||||||
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /rename/i)
|
await clickMenuItem(menu, /rename/i)
|
||||||
|
|
||||||
const input = page.locator(sessionTimelineHeaderSelector).locator(inlineInputSelector).first()
|
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await expect(input).toBeFocused()
|
await expect(input).toBeFocused()
|
||||||
await input.fill(renamedTitle)
|
await input.fill(renamedTitle)
|
||||||
@@ -63,9 +61,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
|
|||||||
)
|
)
|
||||||
.toBe(renamedTitle)
|
.toBe(renamedTitle)
|
||||||
|
|
||||||
await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||||
renamedTitle,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -41,12 +41,220 @@ import { createScrollSpy } from "@/pages/session/scroll-spy"
|
|||||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||||
import { createSessionHistoryWindow, emptyUserMessages } from "@/pages/session/history-window"
|
|
||||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
|
||||||
|
const emptyUserMessages: UserMessage[] = []
|
||||||
|
|
||||||
|
type SessionHistoryWindowInput = {
|
||||||
|
sessionID: () => string | undefined
|
||||||
|
messagesReady: () => boolean
|
||||||
|
visibleUserMessages: () => UserMessage[]
|
||||||
|
historyMore: () => boolean
|
||||||
|
historyLoading: () => boolean
|
||||||
|
loadMore: (sessionID: string) => Promise<void>
|
||||||
|
userScrolled: () => boolean
|
||||||
|
scroller: () => HTMLDivElement | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maintains the rendered history window for a session timeline.
|
||||||
|
*
|
||||||
|
* It keeps initial paint bounded to recent turns, reveals cached turns in
|
||||||
|
* small batches while scrolling upward, and prefetches older history near top.
|
||||||
|
*/
|
||||||
|
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||||
|
const turnInit = 10
|
||||||
|
const turnBatch = 8
|
||||||
|
const turnScrollThreshold = 200
|
||||||
|
const turnPrefetchBuffer = 16
|
||||||
|
const prefetchCooldownMs = 400
|
||||||
|
const prefetchNoGrowthLimit = 2
|
||||||
|
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
turnID: undefined as string | undefined,
|
||||||
|
turnStart: 0,
|
||||||
|
prefetchUntil: 0,
|
||||||
|
prefetchNoGrowth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
|
||||||
|
|
||||||
|
const turnStart = createMemo(() => {
|
||||||
|
const id = input.sessionID()
|
||||||
|
const len = input.visibleUserMessages().length
|
||||||
|
if (!id || len <= 0) return 0
|
||||||
|
if (state.turnID !== id) return initialTurnStart(len)
|
||||||
|
if (state.turnStart <= 0) return 0
|
||||||
|
if (state.turnStart >= len) return initialTurnStart(len)
|
||||||
|
return state.turnStart
|
||||||
|
})
|
||||||
|
|
||||||
|
const setTurnStart = (start: number) => {
|
||||||
|
const id = input.sessionID()
|
||||||
|
const next = start > 0 ? start : 0
|
||||||
|
if (!id) {
|
||||||
|
setState({ turnID: undefined, turnStart: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setState({ turnID: id, turnStart: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderedUserMessages = createMemo(
|
||||||
|
() => {
|
||||||
|
const msgs = input.visibleUserMessages()
|
||||||
|
const start = turnStart()
|
||||||
|
if (start <= 0) return msgs
|
||||||
|
return msgs.slice(start)
|
||||||
|
},
|
||||||
|
emptyUserMessages,
|
||||||
|
{
|
||||||
|
equals: same,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const preserveScroll = (fn: () => void) => {
|
||||||
|
const el = input.scroller()
|
||||||
|
if (!el) {
|
||||||
|
fn()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const beforeTop = el.scrollTop
|
||||||
|
const beforeHeight = el.scrollHeight
|
||||||
|
fn()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const delta = el.scrollHeight - beforeHeight
|
||||||
|
if (!delta) return
|
||||||
|
el.scrollTop = beforeTop + delta
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const backfillTurns = () => {
|
||||||
|
const start = turnStart()
|
||||||
|
if (start <= 0) return
|
||||||
|
|
||||||
|
const next = start - turnBatch
|
||||||
|
const nextStart = next > 0 ? next : 0
|
||||||
|
|
||||||
|
preserveScroll(() => setTurnStart(nextStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
|
||||||
|
const loadAndReveal = async () => {
|
||||||
|
const id = input.sessionID()
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const start = turnStart()
|
||||||
|
const beforeVisible = input.visibleUserMessages().length
|
||||||
|
|
||||||
|
if (start > 0) setTurnStart(0)
|
||||||
|
|
||||||
|
if (!input.historyMore() || input.historyLoading()) return
|
||||||
|
|
||||||
|
await input.loadMore(id)
|
||||||
|
if (input.sessionID() !== id) return
|
||||||
|
|
||||||
|
const afterVisible = input.visibleUserMessages().length
|
||||||
|
const growth = afterVisible - beforeVisible
|
||||||
|
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
|
||||||
|
if (growth <= 0) return
|
||||||
|
if (turnStart() !== 0) return
|
||||||
|
|
||||||
|
const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
|
||||||
|
const nextStart = Math.max(0, afterVisible - target)
|
||||||
|
preserveScroll(() => setTurnStart(nextStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scroll/prefetch path: fetch older history from server. */
|
||||||
|
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
|
||||||
|
const id = input.sessionID()
|
||||||
|
if (!id) return
|
||||||
|
if (!input.historyMore() || input.historyLoading()) return
|
||||||
|
|
||||||
|
if (opts?.prefetch) {
|
||||||
|
const now = Date.now()
|
||||||
|
if (state.prefetchUntil > now) return
|
||||||
|
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
|
||||||
|
setState("prefetchUntil", now + prefetchCooldownMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = turnStart()
|
||||||
|
const beforeVisible = input.visibleUserMessages().length
|
||||||
|
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
|
||||||
|
|
||||||
|
await input.loadMore(id)
|
||||||
|
if (input.sessionID() !== id) return
|
||||||
|
|
||||||
|
const afterVisible = input.visibleUserMessages().length
|
||||||
|
const growth = afterVisible - beforeVisible
|
||||||
|
|
||||||
|
if (opts?.prefetch) {
|
||||||
|
setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
|
||||||
|
} else if (growth > 0 && state.prefetchNoGrowth) {
|
||||||
|
setState("prefetchNoGrowth", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (growth <= 0) return
|
||||||
|
if (turnStart() !== start) return
|
||||||
|
|
||||||
|
const reveal = !opts?.prefetch
|
||||||
|
const currentRendered = renderedUserMessages().length
|
||||||
|
const base = Math.max(beforeRendered, currentRendered)
|
||||||
|
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
|
||||||
|
const nextStart = Math.max(0, afterVisible - target)
|
||||||
|
preserveScroll(() => setTurnStart(nextStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScrollerScroll = () => {
|
||||||
|
if (!input.userScrolled()) return
|
||||||
|
const el = input.scroller()
|
||||||
|
if (!el) return
|
||||||
|
if (el.scrollTop >= turnScrollThreshold) return
|
||||||
|
|
||||||
|
const start = turnStart()
|
||||||
|
if (start > 0) {
|
||||||
|
if (start <= turnPrefetchBuffer) {
|
||||||
|
void fetchOlderMessages({ prefetch: true })
|
||||||
|
}
|
||||||
|
backfillTurns()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchOlderMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
input.sessionID,
|
||||||
|
() => {
|
||||||
|
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [input.sessionID(), input.messagesReady()] as const,
|
||||||
|
([id, ready]) => {
|
||||||
|
if (!id || !ready) return
|
||||||
|
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
turnStart,
|
||||||
|
setTurnStart,
|
||||||
|
renderedUserMessages,
|
||||||
|
loadAndReveal,
|
||||||
|
onScrollerScroll,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -886,7 +1094,6 @@ export default function Page() {
|
|||||||
|
|
||||||
let scrollStateFrame: number | undefined
|
let scrollStateFrame: number | undefined
|
||||||
let scrollStateTarget: HTMLDivElement | undefined
|
let scrollStateTarget: HTMLDivElement | undefined
|
||||||
let historyFillFrame: number | undefined
|
|
||||||
const scrollSpy = createScrollSpy({
|
const scrollSpy = createScrollSpy({
|
||||||
onActive: (id) => {
|
onActive: (id) => {
|
||||||
if (id === store.messageId) return
|
if (id === store.messageId) return
|
||||||
@@ -897,7 +1104,7 @@ export default function Page() {
|
|||||||
const updateScrollState = (el: HTMLDivElement) => {
|
const updateScrollState = (el: HTMLDivElement) => {
|
||||||
const max = el.scrollHeight - el.clientHeight
|
const max = el.scrollHeight - el.clientHeight
|
||||||
const overflow = max > 1
|
const overflow = max > 1
|
||||||
const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
|
const bottom = !overflow || el.scrollTop >= max - 2
|
||||||
|
|
||||||
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
|
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
|
||||||
setUi("scroll", { overflow, bottom })
|
setUi("scroll", { overflow, bottom })
|
||||||
@@ -920,7 +1127,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const resumeScroll = () => {
|
const resumeScroll = () => {
|
||||||
setStore("messageId", undefined)
|
setStore("messageId", undefined)
|
||||||
autoScroll.smoothScrollToBottom()
|
autoScroll.forceScrollToBottom()
|
||||||
clearMessageHash()
|
clearMessageHash()
|
||||||
|
|
||||||
const el = scroller
|
const el = scroller
|
||||||
@@ -956,9 +1163,7 @@ export default function Page() {
|
|||||||
scroller = el
|
scroller = el
|
||||||
autoScroll.scrollRef(el)
|
autoScroll.scrollRef(el)
|
||||||
scrollSpy.setContainer(el)
|
scrollSpy.setContainer(el)
|
||||||
if (!el) return
|
if (el) scheduleScrollState(el)
|
||||||
scheduleScrollState(el)
|
|
||||||
scheduleHistoryFill()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createResizeObserver(
|
createResizeObserver(
|
||||||
@@ -967,7 +1172,6 @@ export default function Page() {
|
|||||||
const el = scroller
|
const el = scroller
|
||||||
if (el) scheduleScrollState(el)
|
if (el) scheduleScrollState(el)
|
||||||
scrollSpy.markDirty()
|
scrollSpy.markDirty()
|
||||||
scheduleHistoryFill()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -982,45 +1186,6 @@ export default function Page() {
|
|||||||
scroller: () => scroller,
|
scroller: () => scroller,
|
||||||
})
|
})
|
||||||
|
|
||||||
const scheduleHistoryFill = () => {
|
|
||||||
if (historyFillFrame !== undefined) return
|
|
||||||
|
|
||||||
historyFillFrame = requestAnimationFrame(() => {
|
|
||||||
historyFillFrame = undefined
|
|
||||||
|
|
||||||
if (!params.id || !messagesReady()) return
|
|
||||||
if (autoScroll.userScrolled() || historyLoading()) return
|
|
||||||
|
|
||||||
const el = scroller
|
|
||||||
if (!el) return
|
|
||||||
if (el.scrollHeight > el.clientHeight + 1) return
|
|
||||||
if (historyWindow.turnStart() <= 0 && !historyMore()) return
|
|
||||||
|
|
||||||
void historyWindow.loadAndReveal()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
params.id,
|
|
||||||
messagesReady(),
|
|
||||||
historyWindow.turnStart(),
|
|
||||||
historyMore(),
|
|
||||||
historyLoading(),
|
|
||||||
autoScroll.userScrolled(),
|
|
||||||
visibleUserMessages().length,
|
|
||||||
] as const,
|
|
||||||
([id, ready, start, more, loading, scrolled]) => {
|
|
||||||
if (!id || !ready || loading || scrolled) return
|
|
||||||
if (start <= 0 && !more) return
|
|
||||||
scheduleHistoryFill()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createResizeObserver(
|
createResizeObserver(
|
||||||
() => promptDock,
|
() => promptDock,
|
||||||
({ height }) => {
|
({ height }) => {
|
||||||
@@ -1030,15 +1195,16 @@ export default function Page() {
|
|||||||
|
|
||||||
const el = scroller
|
const el = scroller
|
||||||
const delta = next - dockHeight
|
const delta = next - dockHeight
|
||||||
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
|
const stick = el
|
||||||
|
? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
|
||||||
|
: false
|
||||||
|
|
||||||
dockHeight = next
|
dockHeight = next
|
||||||
|
|
||||||
if (stick) autoScroll.smoothScrollToBottom()
|
if (stick) autoScroll.forceScrollToBottom()
|
||||||
|
|
||||||
if (el) scheduleScrollState(el)
|
if (el) scheduleScrollState(el)
|
||||||
scrollSpy.markDirty()
|
scrollSpy.markDirty()
|
||||||
scheduleHistoryFill()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1068,7 +1234,6 @@ export default function Page() {
|
|||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
scrollSpy.destroy()
|
scrollSpy.destroy()
|
||||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||||
if (historyFillFrame !== undefined) cancelAnimationFrame(historyFillFrame)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1122,7 +1287,6 @@ export default function Page() {
|
|||||||
onScrollSpyScroll={scrollSpy.onScroll}
|
onScrollSpyScroll={scrollSpy.onScroll}
|
||||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||||
onPreserveScrollAnchor={autoScroll.preserve}
|
|
||||||
centered={centered()}
|
centered={centered()}
|
||||||
setContentRef={(el) => {
|
setContentRef={(el) => {
|
||||||
content = el
|
content = el
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function SessionComposerRegion(props: {
|
|||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"w-full px-3 pointer-events-auto": true,
|
"w-full px-3 pointer-events-auto": true,
|
||||||
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={props.state.questionRequest()} keyed>
|
<Show when={props.state.questionRequest()} keyed>
|
||||||
|
|||||||
@@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs.Content value={props.tab} class="mt-3 relative flex h-full min-h-0 flex-col overflow-hidden contain-strict">
|
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
|
||||||
<ScrollView
|
<ScrollView
|
||||||
class="h-full min-h-0 flex-1"
|
class="h-full"
|
||||||
viewportRef={(el: HTMLDivElement) => {
|
viewportRef={(el: HTMLDivElement) => {
|
||||||
scroll = el
|
scroll = el
|
||||||
restoreScroll()
|
restoreScroll()
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { historyLoadMode, historyRevealTop } from "./history-window"
|
|
||||||
|
|
||||||
describe("historyLoadMode", () => {
|
|
||||||
test("reveals cached turns before fetching", () => {
|
|
||||||
expect(historyLoadMode({ start: 10, more: true, loading: false })).toBe("reveal")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("fetches older history when cache is already revealed", () => {
|
|
||||||
expect(historyLoadMode({ start: 0, more: true, loading: false })).toBe("fetch")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does nothing while history is unavailable or loading", () => {
|
|
||||||
expect(historyLoadMode({ start: 0, more: false, loading: false })).toBe("noop")
|
|
||||||
expect(historyLoadMode({ start: 0, more: true, loading: true })).toBe("noop")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("historyRevealTop", () => {
|
|
||||||
test("pins the viewport to the top when older turns were revealed there", () => {
|
|
||||||
expect(historyRevealTop({ top: -400, height: 1000, gap: 0, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
|
|
||||||
-1400,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps the latest turns pinned when the viewport was underfilled", () => {
|
|
||||||
expect(historyRevealTop({ top: 0, height: 200, gap: -400, max: -400 }, { clientHeight: 600, height: 2000 })).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps the current anchor when the user was not at the top", () => {
|
|
||||||
expect(historyRevealTop({ top: -200, height: 1000, gap: 200, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
|
|
||||||
-200,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
|
||||||
import { createEffect, createMemo, on } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { same } from "@/utils/same"
|
|
||||||
|
|
||||||
export const emptyUserMessages: UserMessage[] = []
|
|
||||||
|
|
||||||
export type SessionHistoryWindowInput = {
|
|
||||||
sessionID: () => string | undefined
|
|
||||||
messagesReady: () => boolean
|
|
||||||
visibleUserMessages: () => UserMessage[]
|
|
||||||
historyMore: () => boolean
|
|
||||||
historyLoading: () => boolean
|
|
||||||
loadMore: (sessionID: string) => Promise<void>
|
|
||||||
userScrolled: () => boolean
|
|
||||||
scroller: () => HTMLDivElement | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
type Snap = {
|
|
||||||
top: number
|
|
||||||
height: number
|
|
||||||
gap: number
|
|
||||||
max: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const historyLoadMode = (input: { start: number; more: boolean; loading: boolean }) => {
|
|
||||||
if (input.start > 0) return "reveal"
|
|
||||||
if (!input.more || input.loading) return "noop"
|
|
||||||
return "fetch"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const historyRevealTop = (
|
|
||||||
mark: { top: number; height: number; gap: number; max: number },
|
|
||||||
next: { clientHeight: number; height: number },
|
|
||||||
threshold = 16,
|
|
||||||
) => {
|
|
||||||
const delta = next.height - mark.height
|
|
||||||
if (delta <= 0) return mark.top
|
|
||||||
if (mark.max <= 0) return mark.top
|
|
||||||
if (mark.gap > threshold) return mark.top
|
|
||||||
|
|
||||||
const max = next.height - next.clientHeight
|
|
||||||
if (max <= 0) return 0
|
|
||||||
return Math.max(-max, Math.min(0, mark.top - delta))
|
|
||||||
}
|
|
||||||
|
|
||||||
const snap = (el: HTMLDivElement | undefined): Snap | undefined => {
|
|
||||||
if (!el) return
|
|
||||||
const max = el.scrollHeight - el.clientHeight
|
|
||||||
return {
|
|
||||||
top: el.scrollTop,
|
|
||||||
height: el.scrollHeight,
|
|
||||||
gap: max + el.scrollTop,
|
|
||||||
max,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clamp = (el: HTMLDivElement, top: number) => {
|
|
||||||
const max = el.scrollHeight - el.clientHeight
|
|
||||||
if (max <= 0) return 0
|
|
||||||
return Math.max(-max, Math.min(0, top))
|
|
||||||
}
|
|
||||||
|
|
||||||
const revealThreshold = 16
|
|
||||||
|
|
||||||
const reveal = (input: SessionHistoryWindowInput, mark: Snap | undefined) => {
|
|
||||||
const el = input.scroller()
|
|
||||||
if (!el || !mark) return
|
|
||||||
el.scrollTop = clamp(
|
|
||||||
el,
|
|
||||||
historyRevealTop(mark, { clientHeight: el.clientHeight, height: el.scrollHeight }, revealThreshold),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const preserve = (input: SessionHistoryWindowInput, fn: () => void) => {
|
|
||||||
const el = input.scroller()
|
|
||||||
if (!el) {
|
|
||||||
fn()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const top = el.scrollTop
|
|
||||||
fn()
|
|
||||||
el.scrollTop = top
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maintains the rendered history window for a session timeline.
|
|
||||||
*
|
|
||||||
* It keeps initial paint bounded to recent turns, reveals cached turns in
|
|
||||||
* small batches while scrolling upward, and prefetches older history near top.
|
|
||||||
*/
|
|
||||||
export function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
|
||||||
const turnInit = 10
|
|
||||||
const turnBatch = 8
|
|
||||||
const turnScrollThreshold = 200
|
|
||||||
const turnPrefetchBuffer = 16
|
|
||||||
const prefetchCooldownMs = 400
|
|
||||||
const prefetchNoGrowthLimit = 2
|
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
|
||||||
turnID: undefined as string | undefined,
|
|
||||||
turnStart: 0,
|
|
||||||
prefetchUntil: 0,
|
|
||||||
prefetchNoGrowth: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
|
|
||||||
|
|
||||||
const turnStart = createMemo(() => {
|
|
||||||
const id = input.sessionID()
|
|
||||||
const len = input.visibleUserMessages().length
|
|
||||||
if (!id || len <= 0) return 0
|
|
||||||
if (state.turnID !== id) return initialTurnStart(len)
|
|
||||||
if (state.turnStart <= 0) return 0
|
|
||||||
if (state.turnStart >= len) return initialTurnStart(len)
|
|
||||||
return state.turnStart
|
|
||||||
})
|
|
||||||
|
|
||||||
const setTurnStart = (start: number) => {
|
|
||||||
const id = input.sessionID()
|
|
||||||
const next = start > 0 ? start : 0
|
|
||||||
if (!id) {
|
|
||||||
setState({ turnID: undefined, turnStart: next })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setState({ turnID: id, turnStart: next })
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderedUserMessages = createMemo(
|
|
||||||
() => {
|
|
||||||
const msgs = input.visibleUserMessages()
|
|
||||||
const start = turnStart()
|
|
||||||
if (start <= 0) return msgs
|
|
||||||
return msgs.slice(start)
|
|
||||||
},
|
|
||||||
emptyUserMessages,
|
|
||||||
{
|
|
||||||
equals: same,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const backfillTurns = () => {
|
|
||||||
const start = turnStart()
|
|
||||||
if (start <= 0) return
|
|
||||||
|
|
||||||
const next = start - turnBatch
|
|
||||||
const nextStart = next > 0 ? next : 0
|
|
||||||
|
|
||||||
preserve(input, () => setTurnStart(nextStart))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Button path: reveal cached turns first, then fetch older history. */
|
|
||||||
const loadAndReveal = async () => {
|
|
||||||
const id = input.sessionID()
|
|
||||||
if (!id) return
|
|
||||||
|
|
||||||
const start = turnStart()
|
|
||||||
const mode = historyLoadMode({
|
|
||||||
start,
|
|
||||||
more: input.historyMore(),
|
|
||||||
loading: input.historyLoading(),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (mode === "reveal") {
|
|
||||||
const mark = snap(input.scroller())
|
|
||||||
setTurnStart(0)
|
|
||||||
reveal(input, mark)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === "noop") return
|
|
||||||
|
|
||||||
const beforeVisible = input.visibleUserMessages().length
|
|
||||||
const mark = snap(input.scroller())
|
|
||||||
|
|
||||||
await input.loadMore(id)
|
|
||||||
if (input.sessionID() !== id) return
|
|
||||||
|
|
||||||
const afterVisible = input.visibleUserMessages().length
|
|
||||||
const growth = afterVisible - beforeVisible
|
|
||||||
if (growth <= 0) return
|
|
||||||
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
|
|
||||||
|
|
||||||
reveal(input, mark)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Scroll/prefetch path: fetch older history from server. */
|
|
||||||
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
|
|
||||||
const id = input.sessionID()
|
|
||||||
if (!id) return
|
|
||||||
if (!input.historyMore() || input.historyLoading()) return
|
|
||||||
|
|
||||||
if (opts?.prefetch) {
|
|
||||||
const now = Date.now()
|
|
||||||
if (state.prefetchUntil > now) return
|
|
||||||
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
|
|
||||||
setState("prefetchUntil", now + prefetchCooldownMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = turnStart()
|
|
||||||
const beforeVisible = input.visibleUserMessages().length
|
|
||||||
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
|
|
||||||
|
|
||||||
await input.loadMore(id)
|
|
||||||
if (input.sessionID() !== id) return
|
|
||||||
|
|
||||||
const afterVisible = input.visibleUserMessages().length
|
|
||||||
const growth = afterVisible - beforeVisible
|
|
||||||
|
|
||||||
if (opts?.prefetch) {
|
|
||||||
setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
|
|
||||||
} else if (growth > 0 && state.prefetchNoGrowth) {
|
|
||||||
setState("prefetchNoGrowth", 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (growth <= 0) return
|
|
||||||
if (turnStart() !== start) return
|
|
||||||
|
|
||||||
const revealMore = !opts?.prefetch
|
|
||||||
const currentRendered = renderedUserMessages().length
|
|
||||||
const base = Math.max(beforeRendered, currentRendered)
|
|
||||||
const target = revealMore ? Math.min(afterVisible, base + turnBatch) : base
|
|
||||||
const nextStart = Math.max(0, afterVisible - target)
|
|
||||||
preserve(input, () => setTurnStart(nextStart))
|
|
||||||
}
|
|
||||||
|
|
||||||
const onScrollerScroll = () => {
|
|
||||||
if (!input.userScrolled()) return
|
|
||||||
const el = input.scroller()
|
|
||||||
if (!el) return
|
|
||||||
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
|
|
||||||
|
|
||||||
const start = turnStart()
|
|
||||||
if (start > 0) {
|
|
||||||
if (start <= turnPrefetchBuffer) {
|
|
||||||
void fetchOlderMessages({ prefetch: true })
|
|
||||||
}
|
|
||||||
backfillTurns()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void fetchOlderMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
input.sessionID,
|
|
||||||
() => {
|
|
||||||
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => [input.sessionID(), input.messagesReady()] as const,
|
|
||||||
([id, ready]) => {
|
|
||||||
if (!id || !ready) return
|
|
||||||
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
turnStart,
|
|
||||||
setTurnStart,
|
|
||||||
renderedUserMessages,
|
|
||||||
loadAndReveal,
|
|
||||||
onScrollerScroll,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,27 @@
|
|||||||
import {
|
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
|
||||||
For,
|
import { createStore, produce } from "solid-js/store"
|
||||||
Index,
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
createSignal,
|
|
||||||
on,
|
|
||||||
onCleanup,
|
|
||||||
Show,
|
|
||||||
startTransition,
|
|
||||||
type JSX,
|
|
||||||
} from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||||
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||||
import { SessionTimelineHeader } from "@/pages/session/session-timeline-header"
|
|
||||||
|
|
||||||
type MessageComment = {
|
type MessageComment = {
|
||||||
path: string
|
path: string
|
||||||
@@ -37,9 +33,7 @@ type MessageComment = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const emptyMessages: MessageType[] = []
|
const emptyMessages: MessageType[] = []
|
||||||
|
const idle = { type: "idle" as const }
|
||||||
const isDefaultSessionTitle = (title?: string) =>
|
|
||||||
!!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
|
|
||||||
|
|
||||||
const messageComments = (parts: Part[]): MessageComment[] =>
|
const messageComments = (parts: Part[]): MessageComment[] =>
|
||||||
parts.flatMap((part) => {
|
parts.flatMap((part) => {
|
||||||
@@ -116,8 +110,6 @@ function createTimelineStaging(input: TimelineStageInput) {
|
|||||||
completedSession: "",
|
completedSession: "",
|
||||||
count: 0,
|
count: 0,
|
||||||
})
|
})
|
||||||
const [readySession, setReadySession] = createSignal("")
|
|
||||||
let active = ""
|
|
||||||
|
|
||||||
const stagedCount = createMemo(() => {
|
const stagedCount = createMemo(() => {
|
||||||
const total = input.messages().length
|
const total = input.messages().length
|
||||||
@@ -142,46 +134,23 @@ function createTimelineStaging(input: TimelineStageInput) {
|
|||||||
cancelAnimationFrame(frame)
|
cancelAnimationFrame(frame)
|
||||||
frame = undefined
|
frame = undefined
|
||||||
}
|
}
|
||||||
const scheduleReady = (sessionKey: string) => {
|
|
||||||
if (input.sessionKey() !== sessionKey) return
|
|
||||||
if (readySession() === sessionKey) return
|
|
||||||
setReadySession(sessionKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
|
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
|
||||||
([sessionKey, isWindowed, total]) => {
|
([sessionKey, isWindowed, total]) => {
|
||||||
const switched = active !== sessionKey
|
|
||||||
if (switched) {
|
|
||||||
active = sessionKey
|
|
||||||
setReadySession("")
|
|
||||||
}
|
|
||||||
|
|
||||||
const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey
|
|
||||||
const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey
|
|
||||||
|
|
||||||
if (staging && !switched && shouldStage && frame !== undefined) return
|
|
||||||
|
|
||||||
cancel()
|
cancel()
|
||||||
|
const shouldStage =
|
||||||
if (shouldStage) setReadySession("")
|
isWindowed &&
|
||||||
|
total > input.config.init &&
|
||||||
|
state.completedSession !== sessionKey &&
|
||||||
|
state.activeSession !== sessionKey
|
||||||
if (!shouldStage) {
|
if (!shouldStage) {
|
||||||
setState({
|
setState({ activeSession: "", count: total })
|
||||||
activeSession: "",
|
|
||||||
completedSession: isWindowed ? sessionKey : state.completedSession,
|
|
||||||
count: total,
|
|
||||||
})
|
|
||||||
if (total <= 0) {
|
|
||||||
setReadySession("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (readySession() !== sessionKey) scheduleReady(sessionKey)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let count = Math.min(total, input.config.init)
|
let count = Math.min(total, input.config.init)
|
||||||
if (staging) count = Math.min(total, Math.max(count, state.count))
|
|
||||||
setState({ activeSession: sessionKey, count })
|
setState({ activeSession: sessionKey, count })
|
||||||
|
|
||||||
const step = () => {
|
const step = () => {
|
||||||
@@ -191,11 +160,10 @@ function createTimelineStaging(input: TimelineStageInput) {
|
|||||||
}
|
}
|
||||||
const currentTotal = input.messages().length
|
const currentTotal = input.messages().length
|
||||||
count = Math.min(currentTotal, count + input.config.batch)
|
count = Math.min(currentTotal, count + input.config.batch)
|
||||||
startTransition(() => setState("count", count))
|
setState("count", count)
|
||||||
if (count >= currentTotal) {
|
if (count >= currentTotal) {
|
||||||
setState({ completedSession: sessionKey, activeSession: "" })
|
setState({ completedSession: sessionKey, activeSession: "" })
|
||||||
frame = undefined
|
frame = undefined
|
||||||
scheduleReady(sessionKey)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
frame = requestAnimationFrame(step)
|
frame = requestAnimationFrame(step)
|
||||||
@@ -209,12 +177,9 @@ function createTimelineStaging(input: TimelineStageInput) {
|
|||||||
const key = input.sessionKey()
|
const key = input.sessionKey()
|
||||||
return state.activeSession === key && state.completedSession !== key
|
return state.activeSession === key && state.completedSession !== key
|
||||||
})
|
})
|
||||||
const ready = createMemo(() => readySession() === input.sessionKey())
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(cancel)
|
||||||
cancel()
|
return { messages: stagedUserMessages, isStaging }
|
||||||
})
|
|
||||||
return { messages: stagedUserMessages, isStaging, ready }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageTimeline(props: {
|
export function MessageTimeline(props: {
|
||||||
@@ -231,7 +196,6 @@ export function MessageTimeline(props: {
|
|||||||
onScrollSpyScroll: () => void
|
onScrollSpyScroll: () => void
|
||||||
onTurnBackfillScroll: () => void
|
onTurnBackfillScroll: () => void
|
||||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||||
onPreserveScrollAnchor: (target: HTMLElement) => void
|
|
||||||
centered: boolean
|
centered: boolean
|
||||||
setContentRef: (el: HTMLDivElement) => void
|
setContentRef: (el: HTMLDivElement) => void
|
||||||
turnStart: number
|
turnStart: number
|
||||||
@@ -246,19 +210,14 @@ export function MessageTimeline(props: {
|
|||||||
let touchGesture: number | undefined
|
let touchGesture: number | undefined
|
||||||
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const trigger = (target: EventTarget | null) => {
|
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
|
||||||
const next =
|
|
||||||
target instanceof Element
|
|
||||||
? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]')
|
|
||||||
: undefined
|
|
||||||
if (!(next instanceof HTMLElement)) return
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const sessionID = createMemo(() => params.id)
|
const sessionID = createMemo(() => params.id)
|
||||||
const sessionMessages = createMemo(() => {
|
const sessionMessages = createMemo(() => {
|
||||||
@@ -271,20 +230,28 @@ export function MessageTimeline(props: {
|
|||||||
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
|
const sessionStatus = createMemo(() => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id) return idle
|
||||||
|
return sync.data.session_status[id] ?? idle
|
||||||
|
})
|
||||||
const activeMessageID = createMemo(() => {
|
const activeMessageID = createMemo(() => {
|
||||||
const messages = sessionMessages()
|
const parentID = pending()?.parentID
|
||||||
const message = pending()
|
if (parentID) {
|
||||||
if (message?.parentID) {
|
const messages = sessionMessages()
|
||||||
const result = Binary.search(messages, message.parentID, (item) => item.id)
|
const result = Binary.search(messages, parentID, (message) => message.id)
|
||||||
const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID)
|
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
|
||||||
if (parent?.role === "user") return parent.id
|
if (message && message.role === "user") return message.id
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionStatus() === "idle") return undefined
|
const status = sessionStatus()
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
if (status.type !== "idle") {
|
||||||
if (messages[i].role === "user") return messages[i].id
|
const messages = sessionMessages()
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].role === "user") return messages[i].id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
const info = createMemo(() => {
|
const info = createMemo(() => {
|
||||||
@@ -292,19 +259,9 @@ export function MessageTimeline(props: {
|
|||||||
if (!id) return
|
if (!id) return
|
||||||
return sync.session.get(id)
|
return sync.session.get(id)
|
||||||
})
|
})
|
||||||
const titleValue = createMemo(() => {
|
const titleValue = createMemo(() => info()?.title)
|
||||||
const title = info()?.title
|
|
||||||
if (!title) return
|
|
||||||
if (isDefaultSessionTitle(title)) return language.t("command.session.new")
|
|
||||||
return title
|
|
||||||
})
|
|
||||||
const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title))
|
|
||||||
const headerTitle = createMemo(
|
|
||||||
() => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined),
|
|
||||||
)
|
|
||||||
const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0))
|
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
const showHeader = createMemo(() => !!(headerTitle() || parentID()))
|
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||||
const stageCfg = { init: 1, batch: 3 }
|
const stageCfg = { init: 1, batch: 3 }
|
||||||
const staging = createTimelineStaging({
|
const staging = createTimelineStaging({
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@@ -312,7 +269,212 @@ export function MessageTimeline(props: {
|
|||||||
messages: () => props.renderedUserMessages,
|
messages: () => props.renderedUserMessages,
|
||||||
config: stageCfg,
|
config: stageCfg,
|
||||||
})
|
})
|
||||||
const rendered = createMemo(() => staging.messages().map((message) => message.id))
|
|
||||||
|
const [title, setTitle] = createStore({
|
||||||
|
draft: "",
|
||||||
|
editing: false,
|
||||||
|
saving: false,
|
||||||
|
menuOpen: false,
|
||||||
|
pendingRename: false,
|
||||||
|
})
|
||||||
|
let titleRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
|
const errorMessage = (err: unknown) => {
|
||||||
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
|
const data = (err as { data?: { message?: string } }).data
|
||||||
|
if (data?.message) return data.message
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message
|
||||||
|
return language.t("common.requestFailed")
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
sessionKey,
|
||||||
|
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const openTitleEditor = () => {
|
||||||
|
if (!sessionID()) return
|
||||||
|
setTitle({ editing: true, draft: titleValue() ?? "" })
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
titleRef?.focus()
|
||||||
|
titleRef?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeTitleEditor = () => {
|
||||||
|
if (title.saving) return
|
||||||
|
setTitle({ editing: false, saving: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveTitleEditor = async () => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id) return
|
||||||
|
if (title.saving) return
|
||||||
|
|
||||||
|
const next = title.draft.trim()
|
||||||
|
if (!next || next === (titleValue() ?? "")) {
|
||||||
|
setTitle({ editing: false, saving: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle("saving", true)
|
||||||
|
await sdk.client.session
|
||||||
|
.update({ sessionID: id, title: next })
|
||||||
|
.then(() => {
|
||||||
|
sync.set(
|
||||||
|
produce((draft) => {
|
||||||
|
const index = draft.session.findIndex((s) => s.id === id)
|
||||||
|
if (index !== -1) draft.session[index].title = next
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setTitle({ editing: false, saving: false })
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setTitle("saving", false)
|
||||||
|
showToast({
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||||
|
if (params.id !== sessionID) return
|
||||||
|
if (parentID) {
|
||||||
|
navigate(`/${params.dir}/session/${parentID}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextSessionID) {
|
||||||
|
navigate(`/${params.dir}/session/${nextSessionID}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(`/${params.dir}/session`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveSession = async (sessionID: string) => {
|
||||||
|
const session = sync.session.get(sessionID)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const sessions = sync.data.session ?? []
|
||||||
|
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||||
|
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||||
|
|
||||||
|
await sdk.client.session
|
||||||
|
.update({ sessionID, time: { archived: Date.now() } })
|
||||||
|
.then(() => {
|
||||||
|
sync.set(
|
||||||
|
produce((draft) => {
|
||||||
|
const index = draft.session.findIndex((s) => s.id === sessionID)
|
||||||
|
if (index !== -1) draft.session.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSession = async (sessionID: string) => {
|
||||||
|
const session = sync.session.get(sessionID)
|
||||||
|
if (!session) return false
|
||||||
|
|
||||||
|
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
|
||||||
|
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||||
|
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||||
|
|
||||||
|
const result = await sdk.client.session
|
||||||
|
.delete({ sessionID })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("session.delete.failed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) return false
|
||||||
|
|
||||||
|
sync.set(
|
||||||
|
produce((draft) => {
|
||||||
|
const removed = new Set<string>([sessionID])
|
||||||
|
|
||||||
|
const byParent = new Map<string, string[]>()
|
||||||
|
for (const item of draft.session) {
|
||||||
|
const parentID = item.parentID
|
||||||
|
if (!parentID) continue
|
||||||
|
const existing = byParent.get(parentID)
|
||||||
|
if (existing) {
|
||||||
|
existing.push(item.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byParent.set(parentID, [item.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = [sessionID]
|
||||||
|
while (stack.length) {
|
||||||
|
const parentID = stack.pop()
|
||||||
|
if (!parentID) continue
|
||||||
|
|
||||||
|
const children = byParent.get(parentID)
|
||||||
|
if (!children) continue
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
if (removed.has(child)) continue
|
||||||
|
removed.add(child)
|
||||||
|
stack.push(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateParent = () => {
|
||||||
|
const id = parentID()
|
||||||
|
if (!id) return
|
||||||
|
navigate(`/${params.dir}/session/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDeleteSession(props: { sessionID: string }) {
|
||||||
|
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
|
||||||
|
const handleDelete = async () => {
|
||||||
|
await deleteSession(props.sessionID)
|
||||||
|
dialog.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={language.t("session.delete.title")} fit>
|
||||||
|
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-14-regular text-text-strong">
|
||||||
|
{language.t("session.delete.confirm", { name: name() })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||||
|
{language.t("session.delete.button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
@@ -336,18 +498,7 @@ export function MessageTimeline(props: {
|
|||||||
<Icon name="arrow-down-to-line" />
|
<Icon name="arrow-down-to-line" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<SessionTimelineHeader
|
|
||||||
centered={props.centered}
|
|
||||||
showHeader={showHeader}
|
|
||||||
sessionKey={sessionKey}
|
|
||||||
sessionID={sessionID}
|
|
||||||
parentID={parentID}
|
|
||||||
titleValue={titleValue}
|
|
||||||
headerTitle={headerTitle}
|
|
||||||
placeholderTitle={placeholderTitle}
|
|
||||||
/>
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
reverse
|
|
||||||
viewportRef={props.setScrollRef}
|
viewportRef={props.setScrollRef}
|
||||||
onWheel={(e) => {
|
onWheel={(e) => {
|
||||||
const root = e.currentTarget
|
const root = e.currentTarget
|
||||||
@@ -381,18 +532,9 @@ export function MessageTimeline(props: {
|
|||||||
touchGesture = undefined
|
touchGesture = undefined
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
const next = trigger(e.target)
|
|
||||||
if (next) props.onPreserveScrollAnchor(next)
|
|
||||||
|
|
||||||
if (e.target !== e.currentTarget) return
|
if (e.target !== e.currentTarget) return
|
||||||
props.onMarkScrollGesture(e.currentTarget)
|
props.onMarkScrollGesture(e.currentTarget)
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key !== "Enter" && e.key !== " ") return
|
|
||||||
const next = trigger(e.target)
|
|
||||||
if (!next) return
|
|
||||||
props.onPreserveScrollAnchor(next)
|
|
||||||
}}
|
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
props.onScheduleScrollState(e.currentTarget)
|
props.onScheduleScrollState(e.currentTarget)
|
||||||
props.onTurnBackfillScroll()
|
props.onTurnBackfillScroll()
|
||||||
@@ -401,24 +543,134 @@ export function MessageTimeline(props: {
|
|||||||
props.onMarkScrollGesture(e.currentTarget)
|
props.onMarkScrollGesture(e.currentTarget)
|
||||||
if (props.isDesktop) props.onScrollSpyScroll()
|
if (props.isDesktop) props.onScrollSpyScroll()
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={props.onAutoScrollInteraction}
|
||||||
props.onAutoScrollInteraction(e)
|
|
||||||
}}
|
|
||||||
class="relative min-w-0 w-full h-full"
|
class="relative min-w-0 w-full h-full"
|
||||||
style={{
|
style={{
|
||||||
"--session-title-height": showHeader() ? "72px" : "0px",
|
"--session-title-height": showHeader() ? "40px" : "0px",
|
||||||
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div ref={props.setContentRef} class="min-w-0 w-full">
|
||||||
|
<Show when={showHeader()}>
|
||||||
|
<div
|
||||||
|
data-session-title
|
||||||
|
classList={{
|
||||||
|
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||||
|
"w-full": true,
|
||||||
|
"pb-4": true,
|
||||||
|
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||||
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||||
|
<Show when={parentID()}>
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
icon="arrow-left"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={navigateParent}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={titleValue() || title.editing}>
|
||||||
|
<Show
|
||||||
|
when={title.editing}
|
||||||
|
fallback={
|
||||||
|
<h1
|
||||||
|
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
||||||
|
onDblClick={openTitleEditor}
|
||||||
|
>
|
||||||
|
{titleValue()}
|
||||||
|
</h1>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InlineInput
|
||||||
|
ref={(el) => {
|
||||||
|
titleRef = el
|
||||||
|
}}
|
||||||
|
value={title.draft}
|
||||||
|
disabled={title.saving}
|
||||||
|
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
|
||||||
|
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||||
|
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
void saveTitleEditor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
closeTitleEditor()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={closeTitleEditor}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={sessionID()}>
|
||||||
|
{(id) => (
|
||||||
|
<div class="shrink-0 flex items-center gap-3">
|
||||||
|
<SessionContextUsage placement="bottom" />
|
||||||
|
<DropdownMenu
|
||||||
|
gutter={4}
|
||||||
|
placement="bottom-end"
|
||||||
|
open={title.menuOpen}
|
||||||
|
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={IconButton}
|
||||||
|
icon="dot-grid"
|
||||||
|
variant="ghost"
|
||||||
|
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||||
|
aria-label={language.t("common.moreOptions")}
|
||||||
|
/>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
style={{ "min-width": "104px" }}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (!title.pendingRename) return
|
||||||
|
event.preventDefault()
|
||||||
|
setTitle("pendingRename", false)
|
||||||
|
openTitleEditor()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => {
|
||||||
|
setTitle("pendingRename", true)
|
||||||
|
setTitle("menuOpen", false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={props.setContentRef}
|
|
||||||
role="log"
|
role="log"
|
||||||
class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
|
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||||
style={{ "padding-top": "var(--session-title-height)" }}
|
|
||||||
classList={{
|
classList={{
|
||||||
"w-full": true,
|
"w-full": true,
|
||||||
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||||
"mt-0.5": props.centered,
|
"mt-0.5": props.centered,
|
||||||
"mt-0": !props.centered,
|
"mt-0": !props.centered,
|
||||||
}}
|
}}
|
||||||
@@ -440,15 +692,6 @@ export function MessageTimeline(props: {
|
|||||||
</Show>
|
</Show>
|
||||||
<For each={rendered()}>
|
<For each={rendered()}>
|
||||||
{(messageID) => {
|
{(messageID) => {
|
||||||
// Capture at creation time: animate only messages added after the
|
|
||||||
// timeline finishes its initial backfill staging, plus the first
|
|
||||||
// turn while a brand new session is still using its default title.
|
|
||||||
const isNew =
|
|
||||||
staging.ready() ||
|
|
||||||
(defaultTitle() &&
|
|
||||||
sessionStatus() !== "idle" &&
|
|
||||||
props.renderedUserMessages.length === 1 &&
|
|
||||||
messageID === props.renderedUserMessages[0]?.id)
|
|
||||||
const active = createMemo(() => activeMessageID() === messageID)
|
const active = createMemo(() => activeMessageID() === messageID)
|
||||||
const queued = createMemo(() => {
|
const queued = createMemo(() => {
|
||||||
if (active()) return false
|
if (active()) return false
|
||||||
@@ -457,10 +700,7 @@ export function MessageTimeline(props: {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
|
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
|
||||||
equals: (a, b) => {
|
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
||||||
if (a.length !== b.length) return false
|
|
||||||
return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
const commentCount = createMemo(() => comments().length)
|
const commentCount = createMemo(() => comments().length)
|
||||||
return (
|
return (
|
||||||
@@ -473,7 +713,7 @@ export function MessageTimeline(props: {
|
|||||||
}}
|
}}
|
||||||
classList={{
|
classList={{
|
||||||
"min-w-0 w-full max-w-full": true,
|
"min-w-0 w-full max-w-full": true,
|
||||||
"md:max-w-[500px] 2xl:max-w-[700px]": props.centered,
|
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={commentCount() > 0}>
|
<Show when={commentCount() > 0}>
|
||||||
@@ -517,7 +757,7 @@ export function MessageTimeline(props: {
|
|||||||
messageID={messageID}
|
messageID={messageID}
|
||||||
active={active()}
|
active={active()}
|
||||||
queued={queued()}
|
queued={queued()}
|
||||||
animate={isNew || active()}
|
status={active() ? sessionStatus() : undefined}
|
||||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||||
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
||||||
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
||||||
|
|||||||
@@ -1,522 +0,0 @@
|
|||||||
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
|
|
||||||
import { createStore, produce } from "solid-js/store"
|
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { useReducedMotion } from "@opencode-ai/ui/hooks"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
|
||||||
import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { errorMessage } from "@/pages/layout/helpers"
|
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useSDK } from "@/context/sdk"
|
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
|
|
||||||
export function SessionTimelineHeader(props: {
|
|
||||||
centered: boolean
|
|
||||||
showHeader: () => boolean
|
|
||||||
sessionKey: () => string
|
|
||||||
sessionID: () => string | undefined
|
|
||||||
parentID: () => string | undefined
|
|
||||||
titleValue: () => string | undefined
|
|
||||||
headerTitle: () => string | undefined
|
|
||||||
placeholderTitle: () => boolean
|
|
||||||
}) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const params = useParams()
|
|
||||||
const sdk = useSDK()
|
|
||||||
const sync = useSync()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const language = useLanguage()
|
|
||||||
const reduce = useReducedMotion()
|
|
||||||
|
|
||||||
const [title, setTitle] = createStore({
|
|
||||||
draft: "",
|
|
||||||
editing: false,
|
|
||||||
saving: false,
|
|
||||||
menuOpen: false,
|
|
||||||
pendingRename: false,
|
|
||||||
})
|
|
||||||
const [headerText, setHeaderText] = createStore({
|
|
||||||
session: props.sessionKey(),
|
|
||||||
value: props.headerTitle(),
|
|
||||||
prev: undefined as string | undefined,
|
|
||||||
muted: props.placeholderTitle(),
|
|
||||||
prevMuted: false,
|
|
||||||
})
|
|
||||||
let headerAnim: AnimationPlaybackControls | undefined
|
|
||||||
let enterAnim: AnimationPlaybackControls | undefined
|
|
||||||
let leaveAnim: AnimationPlaybackControls | undefined
|
|
||||||
let titleRef: HTMLInputElement | undefined
|
|
||||||
let headerRef: HTMLDivElement | undefined
|
|
||||||
let enterRef: HTMLSpanElement | undefined
|
|
||||||
let leaveRef: HTMLSpanElement | undefined
|
|
||||||
|
|
||||||
const clearHeaderAnim = () => {
|
|
||||||
headerAnim?.stop()
|
|
||||||
headerAnim = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const animateHeader = () => {
|
|
||||||
const el = headerRef
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
clearHeaderAnim()
|
|
||||||
if (!headerText.muted || reduce()) {
|
|
||||||
el.style.opacity = "1"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
|
|
||||||
headerAnim.finished.then(() => {
|
|
||||||
if (headerRef !== el) return
|
|
||||||
clearFadeStyles(el)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearTitleAnims = () => {
|
|
||||||
enterAnim?.stop()
|
|
||||||
enterAnim = undefined
|
|
||||||
leaveAnim?.stop()
|
|
||||||
leaveAnim = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const settleTitleEnter = () => {
|
|
||||||
if (enterRef) clearFadeStyles(enterRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideLeave = () => {
|
|
||||||
if (!leaveRef) return
|
|
||||||
leaveRef.style.opacity = "0"
|
|
||||||
leaveRef.style.filter = ""
|
|
||||||
leaveRef.style.transform = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const animateEnterSpan = () => {
|
|
||||||
if (!enterRef) return
|
|
||||||
if (reduce()) {
|
|
||||||
settleTitleEnter()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
enterAnim = animate(
|
|
||||||
enterRef,
|
|
||||||
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
|
|
||||||
FAST_SPRING,
|
|
||||||
)
|
|
||||||
enterAnim.finished.then(() => settleTitleEnter())
|
|
||||||
}
|
|
||||||
|
|
||||||
const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
|
|
||||||
clearTitleAnims()
|
|
||||||
setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
|
|
||||||
setHeaderText({ value: nextTitle, muted: nextMuted })
|
|
||||||
|
|
||||||
if (reduce()) {
|
|
||||||
setHeaderText({ prev: undefined, prevMuted: false })
|
|
||||||
hideLeave()
|
|
||||||
settleTitleEnter()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (leaveRef) {
|
|
||||||
leaveAnim = animate(
|
|
||||||
leaveRef,
|
|
||||||
{ opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
|
|
||||||
FAST_SPRING,
|
|
||||||
)
|
|
||||||
leaveAnim.finished.then(() => {
|
|
||||||
setHeaderText({ prev: undefined, prevMuted: false })
|
|
||||||
hideLeave()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
animateEnterSpan()
|
|
||||||
}
|
|
||||||
|
|
||||||
const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
|
|
||||||
clearTitleAnims()
|
|
||||||
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
|
|
||||||
animateEnterSpan()
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
|
|
||||||
clearTitleAnims()
|
|
||||||
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
|
|
||||||
settleTitleEnter()
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(props.showHeader, (show, prev) => {
|
|
||||||
if (!show) {
|
|
||||||
clearHeaderAnim()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (show === prev) return
|
|
||||||
animateHeader()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const,
|
|
||||||
([nextSession, nextTitle, nextMuted]) => {
|
|
||||||
if (nextSession !== headerText.session) {
|
|
||||||
setHeaderText("session", nextSession)
|
|
||||||
if (nextTitle && nextMuted) {
|
|
||||||
fadeInTitle(nextTitle, nextMuted)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
snapTitle(nextTitle, nextMuted)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (nextTitle === headerText.value && nextMuted === headerText.muted) return
|
|
||||||
if (!nextTitle) {
|
|
||||||
snapTitle(undefined, false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!headerText.value) {
|
|
||||||
fadeInTitle(nextTitle, nextMuted)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (title.saving || title.editing) {
|
|
||||||
snapTitle(nextTitle, nextMuted)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
crossfadeTitle(nextTitle, nextMuted)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
clearHeaderAnim()
|
|
||||||
clearTitleAnims()
|
|
||||||
})
|
|
||||||
|
|
||||||
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
props.sessionKey,
|
|
||||||
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const openTitleEditor = () => {
|
|
||||||
if (!props.sessionID()) return
|
|
||||||
setTitle({ editing: true, draft: props.titleValue() ?? "" })
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
titleRef?.focus()
|
|
||||||
titleRef?.select()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeTitleEditor = () => {
|
|
||||||
if (title.saving) return
|
|
||||||
setTitle({ editing: false, saving: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveTitleEditor = async () => {
|
|
||||||
const id = props.sessionID()
|
|
||||||
if (!id) return
|
|
||||||
if (title.saving) return
|
|
||||||
|
|
||||||
const next = title.draft.trim()
|
|
||||||
if (!next || next === (props.titleValue() ?? "")) {
|
|
||||||
setTitle({ editing: false, saving: false })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setTitle("saving", true)
|
|
||||||
await sdk.client.session
|
|
||||||
.update({ sessionID: id, title: next })
|
|
||||||
.then(() => {
|
|
||||||
sync.set(
|
|
||||||
produce((draft) => {
|
|
||||||
const index = draft.session.findIndex((session) => session.id === id)
|
|
||||||
if (index !== -1) draft.session[index].title = next
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setTitle({ editing: false, saving: false })
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setTitle("saving", false)
|
|
||||||
showToast({
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: toastError(err),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
|
||||||
if (params.id !== sessionID) return
|
|
||||||
if (parentID) {
|
|
||||||
navigate(`/${params.dir}/session/${parentID}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (nextSessionID) {
|
|
||||||
navigate(`/${params.dir}/session/${nextSessionID}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
navigate(`/${params.dir}/session`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const archiveSession = async (sessionID: string) => {
|
|
||||||
const session = sync.session.get(sessionID)
|
|
||||||
if (!session) return
|
|
||||||
|
|
||||||
const sessions = sync.data.session ?? []
|
|
||||||
const index = sessions.findIndex((item) => item.id === sessionID)
|
|
||||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
|
||||||
|
|
||||||
await sdk.client.session
|
|
||||||
.update({ sessionID, time: { archived: Date.now() } })
|
|
||||||
.then(() => {
|
|
||||||
sync.set(
|
|
||||||
produce((draft) => {
|
|
||||||
const index = draft.session.findIndex((item) => item.id === sessionID)
|
|
||||||
if (index !== -1) draft.session.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: toastError(err),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteSession = async (sessionID: string) => {
|
|
||||||
const session = sync.session.get(sessionID)
|
|
||||||
if (!session) return false
|
|
||||||
|
|
||||||
const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived)
|
|
||||||
const index = sessions.findIndex((item) => item.id === sessionID)
|
|
||||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
|
||||||
|
|
||||||
const result = await sdk.client.session
|
|
||||||
.delete({ sessionID })
|
|
||||||
.then((x) => x.data)
|
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
|
||||||
title: language.t("session.delete.failed.title"),
|
|
||||||
description: toastError(err),
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) return false
|
|
||||||
|
|
||||||
sync.set(
|
|
||||||
produce((draft) => {
|
|
||||||
const removed = new Set<string>([sessionID])
|
|
||||||
const byParent = new Map<string, string[]>()
|
|
||||||
|
|
||||||
for (const item of draft.session) {
|
|
||||||
const parentID = item.parentID
|
|
||||||
if (!parentID) continue
|
|
||||||
|
|
||||||
const existing = byParent.get(parentID)
|
|
||||||
if (existing) {
|
|
||||||
existing.push(item.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
byParent.set(parentID, [item.id])
|
|
||||||
}
|
|
||||||
|
|
||||||
const stack = [sessionID]
|
|
||||||
while (stack.length) {
|
|
||||||
const parentID = stack.pop()
|
|
||||||
if (!parentID) continue
|
|
||||||
|
|
||||||
const children = byParent.get(parentID)
|
|
||||||
if (!children) continue
|
|
||||||
|
|
||||||
for (const child of children) {
|
|
||||||
if (removed.has(child)) continue
|
|
||||||
removed.add(child)
|
|
||||||
stack.push(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
draft.session = draft.session.filter((item) => !removed.has(item.id))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateParent = () => {
|
|
||||||
const id = props.parentID()
|
|
||||||
if (!id) return
|
|
||||||
navigate(`/${params.dir}/session/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogDeleteSession(input: { sessionID: string }) {
|
|
||||||
const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new"))
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
await deleteSession(input.sessionID)
|
|
||||||
dialog.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog title={language.t("session.delete.title")} fit>
|
|
||||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="text-14-regular text-text-strong">
|
|
||||||
{language.t("session.delete.confirm", { name: name() })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
|
||||||
{language.t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
|
||||||
{language.t("session.delete.button")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={props.showHeader()}>
|
|
||||||
<div
|
|
||||||
data-session-title
|
|
||||||
ref={(el) => {
|
|
||||||
headerRef = el
|
|
||||||
el.style.opacity = "0"
|
|
||||||
}}
|
|
||||||
class="pointer-events-none absolute inset-x-0 top-0 z-30"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
|
|
||||||
"w-full": true,
|
|
||||||
"pb-10": true,
|
|
||||||
"px-4 md:px-5": true,
|
|
||||||
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
|
|
||||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
|
||||||
<Show when={props.parentID()}>
|
|
||||||
<div>
|
|
||||||
<IconButton
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="arrow-left"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={navigateParent}
|
|
||||||
aria-label={language.t("common.goBack")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={!!headerText.value || title.editing}>
|
|
||||||
<Show
|
|
||||||
when={title.editing}
|
|
||||||
fallback={
|
|
||||||
<h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}>
|
|
||||||
<span class="grid min-w-0" style={{ overflow: "clip" }}>
|
|
||||||
<span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
|
|
||||||
<span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
ref={leaveRef}
|
|
||||||
class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
|
|
||||||
style={{ opacity: "0" }}
|
|
||||||
>
|
|
||||||
<span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<InlineInput
|
|
||||||
ref={(el) => {
|
|
||||||
titleRef = el
|
|
||||||
}}
|
|
||||||
value={title.draft}
|
|
||||||
disabled={title.saving}
|
|
||||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
|
||||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
|
||||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault()
|
|
||||||
void saveTitleEditor()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault()
|
|
||||||
closeTitleEditor()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={closeTitleEditor}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<Show when={props.sessionID()}>
|
|
||||||
{(id) => (
|
|
||||||
<div class="shrink-0 flex items-center gap-3">
|
|
||||||
<SessionContextUsage placement="bottom" />
|
|
||||||
<DropdownMenu
|
|
||||||
gutter={4}
|
|
||||||
placement="bottom-end"
|
|
||||||
open={title.menuOpen}
|
|
||||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButton}
|
|
||||||
icon="dot-grid"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
|
||||||
aria-label={language.t("common.moreOptions")}
|
|
||||||
/>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
style={{ "min-width": "104px" }}
|
|
||||||
onCloseAutoFocus={(event) => {
|
|
||||||
if (!title.pendingRename) return
|
|
||||||
event.preventDefault()
|
|
||||||
setTitle("pendingRename", false)
|
|
||||||
openTitleEditor()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={() => {
|
|
||||||
setTitle("pendingRename", true)
|
|
||||||
setTitle("menuOpen", false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Separator />
|
|
||||||
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}>
|
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
import { useLocation, useNavigate } from "@solidjs/router"
|
||||||
|
import { createEffect, createMemo, onMount } from "solid-js"
|
||||||
import { messageIdFromHash } from "./message-id-from-hash"
|
import { messageIdFromHash } from "./message-id-from-hash"
|
||||||
|
|
||||||
export { messageIdFromHash } from "./message-id-from-hash"
|
export { messageIdFromHash } from "./message-id-from-hash"
|
||||||
@@ -15,7 +16,7 @@ export const useSessionHashScroll = (input: {
|
|||||||
setPendingMessage: (value: string | undefined) => void
|
setPendingMessage: (value: string | undefined) => void
|
||||||
setActiveMessage: (message: UserMessage | undefined) => void
|
setActiveMessage: (message: UserMessage | undefined) => void
|
||||||
setTurnStart: (value: number) => void
|
setTurnStart: (value: number) => void
|
||||||
autoScroll: { pause: () => void; snapToBottom: () => void }
|
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
||||||
scroller: () => HTMLDivElement | undefined
|
scroller: () => HTMLDivElement | undefined
|
||||||
anchor: (id: string) => string
|
anchor: (id: string) => string
|
||||||
scheduleScrollState: (el: HTMLDivElement) => void
|
scheduleScrollState: (el: HTMLDivElement) => void
|
||||||
@@ -26,13 +27,18 @@ export const useSessionHashScroll = (input: {
|
|||||||
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
||||||
let pendingKey = ""
|
let pendingKey = ""
|
||||||
|
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const clearMessageHash = () => {
|
const clearMessageHash = () => {
|
||||||
if (!window.location.hash) return
|
if (!location.hash) return
|
||||||
window.history.replaceState(null, "", window.location.pathname + window.location.search)
|
navigate(location.pathname + location.search, { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateHash = (id: string) => {
|
const updateHash = (id: string) => {
|
||||||
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
|
navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
||||||
@@ -41,15 +47,15 @@ export const useSessionHashScroll = (input: {
|
|||||||
|
|
||||||
const a = el.getBoundingClientRect()
|
const a = el.getBoundingClientRect()
|
||||||
const b = root.getBoundingClientRect()
|
const b = root.getBoundingClientRect()
|
||||||
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
|
const sticky = root.querySelector("[data-session-title]")
|
||||||
const inset = Number.isNaN(title) ? 0 : title
|
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
|
||||||
// With column-reverse, scrollTop is negative — don't clamp to 0
|
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
|
||||||
const top = a.top - b.top + root.scrollTop - inset
|
|
||||||
root.scrollTo({ top, behavior })
|
root.scrollTo({ top, behavior })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||||
|
console.log({ message, behavior })
|
||||||
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
||||||
|
|
||||||
const index = messageIndex().get(message.id) ?? -1
|
const index = messageIndex().get(message.id) ?? -1
|
||||||
@@ -97,9 +103,9 @@ export const useSessionHashScroll = (input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const applyHash = (behavior: ScrollBehavior) => {
|
const applyHash = (behavior: ScrollBehavior) => {
|
||||||
const hash = window.location.hash.slice(1)
|
const hash = location.hash.slice(1)
|
||||||
if (!hash) {
|
if (!hash) {
|
||||||
input.autoScroll.snapToBottom()
|
input.autoScroll.forceScrollToBottom()
|
||||||
const el = input.scroller()
|
const el = input.scroller()
|
||||||
if (el) input.scheduleScrollState(el)
|
if (el) input.scheduleScrollState(el)
|
||||||
return
|
return
|
||||||
@@ -123,26 +129,13 @@ export const useSessionHashScroll = (input: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input.autoScroll.snapToBottom()
|
input.autoScroll.forceScrollToBottom()
|
||||||
const el = input.scroller()
|
const el = input.scroller()
|
||||||
if (el) input.scheduleScrollState(el)
|
if (el) input.scheduleScrollState(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
|
||||||
window.history.scrollRestoration = "manual"
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = () => {
|
|
||||||
if (!input.sessionID() || !input.messagesReady()) return
|
|
||||||
requestAnimationFrame(() => applyHash("auto"))
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("hashchange", handler)
|
|
||||||
onCleanup(() => window.removeEventListener("hashchange", handler))
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
location.hash
|
||||||
if (!input.sessionID() || !input.messagesReady()) return
|
if (!input.sessionID() || !input.messagesReady()) return
|
||||||
requestAnimationFrame(() => applyHash("auto"))
|
requestAnimationFrame(() => applyHash("auto"))
|
||||||
})
|
})
|
||||||
@@ -166,6 +159,7 @@ export const useSessionHashScroll = (input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!targetId) targetId = messageIdFromHash(location.hash)
|
||||||
if (!targetId) return
|
if (!targetId) return
|
||||||
if (input.currentMessageId() === targetId) return
|
if (input.currentMessageId() === targetId) return
|
||||||
|
|
||||||
@@ -177,6 +171,12 @@ export const useSessionHashScroll = (input: {
|
|||||||
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||||||
|
window.history.scrollRestoration = "manual"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clearMessageHash,
|
clearMessageHash,
|
||||||
scrollToMessage,
|
scrollToMessage,
|
||||||
|
|||||||
@@ -48,11 +48,8 @@
|
|||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/bounds": "0.1.3",
|
"@solid-primitives/bounds": "0.1.3",
|
||||||
"@solid-primitives/lifecycle": "0.1.2",
|
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/page-visibility": "2.1.1",
|
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/rootless": "1.5.2",
|
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
|
|||||||
@@ -9,20 +9,19 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: flex-start;
|
justify-content: flex-end;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
width: var(--animated-number-width, 1ch);
|
width: var(--animated-number-width, 1ch);
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
transition: width var(--tool-motion-spring-ms, 800ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="animated-number-digit"] {
|
[data-slot="animated-number-digit"] {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
flex-shrink: 0;
|
|
||||||
width: 1ch;
|
width: 1ch;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
line-height: 1em;
|
line-height: 1em;
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
-webkit-mask-image: linear-gradient(
|
-webkit-mask-image: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
@@ -47,7 +46,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
|
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
|
||||||
transition-property: transform;
|
transition-property: transform;
|
||||||
transition-duration: var(--animated-number-duration, 600ms);
|
transition-duration: var(--animated-number-duration, 560ms);
|
||||||
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js"
|
import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js"
|
||||||
|
|
||||||
const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
|
const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
|
||||||
const DURATION = 800
|
const DURATION = 600
|
||||||
|
|
||||||
function normalize(value: number) {
|
function normalize(value: number) {
|
||||||
return ((value % 10) + 10) % 10
|
return ((value % 10) + 10) % 10
|
||||||
@@ -90,35 +90,10 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
|
|||||||
)
|
)
|
||||||
const width = createMemo(() => `${digits().length}ch`)
|
const width = createMemo(() => `${digits().length}ch`)
|
||||||
|
|
||||||
const [exitingDigits, setExitingDigits] = createSignal<number[]>([])
|
|
||||||
let exitTimer: number | undefined
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
digits,
|
|
||||||
(current, prev) => {
|
|
||||||
if (prev && current.length < prev.length) {
|
|
||||||
setExitingDigits(prev.slice(current.length))
|
|
||||||
clearTimeout(exitTimer)
|
|
||||||
exitTimer = window.setTimeout(() => setExitingDigits([]), DURATION)
|
|
||||||
} else {
|
|
||||||
clearTimeout(exitTimer)
|
|
||||||
setExitingDigits([])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const displayDigits = createMemo(() => {
|
|
||||||
const exiting = exitingDigits()
|
|
||||||
return exiting.length ? [...digits(), ...exiting] : digits()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span data-component="animated-number" class={props.class} aria-label={label()}>
|
<span data-component="animated-number" class={props.class} aria-label={label()}>
|
||||||
<span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
|
<span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
|
||||||
<Index each={displayDigits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
|
<Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,28 +8,54 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-trigger-content"] {
|
[data-slot="basic-tool-tool-trigger-content"] {
|
||||||
width: 100%;
|
width: auto;
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="basic-tool-tool-indicator"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
[data-component="spinner"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="basic-tool-tool-spinner"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-weak);
|
||||||
|
|
||||||
|
[data-component="spinner"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-slot="icon-svg"] {
|
[data-slot="icon-svg"] {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-info"] {
|
[data-slot="basic-tool-tool-info"] {
|
||||||
flex: 1 1 auto;
|
flex: 0 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-info-structured"] {
|
[data-slot="basic-tool-tool-info-structured"] {
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -37,12 +63,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-info-main"] {
|
[data-slot="basic-tool-tool-info-main"] {
|
||||||
flex: 0 1 auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-title"] {
|
[data-slot="basic-tool-tool-title"] {
|
||||||
@@ -54,14 +79,22 @@
|
|||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
letter-spacing: var(--letter-spacing-normal);
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
|
|
||||||
|
&.capitalize {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.agent-title {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-subtitle"] {
|
[data-slot="basic-tool-tool-subtitle"] {
|
||||||
display: inline-block;
|
flex-shrink: 1;
|
||||||
flex: 0 1 auto;
|
|
||||||
max-width: 100%;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -106,7 +139,8 @@
|
|||||||
[data-slot="basic-tool-tool-arg"] {
|
[data-slot="basic-tool-tool-arg"] {
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|||||||
@@ -1,20 +1,8 @@
|
|||||||
import {
|
import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
|
||||||
createEffect,
|
import { animate, type AnimationPlaybackControls } from "motion"
|
||||||
createSignal,
|
|
||||||
For,
|
|
||||||
Match,
|
|
||||||
on,
|
|
||||||
onCleanup,
|
|
||||||
onMount,
|
|
||||||
Show,
|
|
||||||
splitProps,
|
|
||||||
Switch,
|
|
||||||
type JSX,
|
|
||||||
} from "solid-js"
|
|
||||||
import { animate, type AnimationPlaybackControls, tunableSpringValue, COLLAPSIBLE_SPRING } from "./motion"
|
|
||||||
import { Collapsible } from "./collapsible"
|
import { Collapsible } from "./collapsible"
|
||||||
|
import type { IconProps } from "./icon"
|
||||||
import { TextShimmer } from "./text-shimmer"
|
import { TextShimmer } from "./text-shimmer"
|
||||||
import { hold } from "./tool-utils"
|
|
||||||
|
|
||||||
export type TriggerTitle = {
|
export type TriggerTitle = {
|
||||||
title: string
|
title: string
|
||||||
@@ -32,99 +20,26 @@ const isTriggerTitle = (val: any): val is TriggerTitle => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolCallPanelBaseProps {
|
export interface BasicToolProps {
|
||||||
icon: string
|
icon: IconProps["name"]
|
||||||
trigger: TriggerTitle | JSX.Element
|
trigger: TriggerTitle | JSX.Element
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
status?: string
|
status?: string
|
||||||
animate?: boolean
|
|
||||||
hideDetails?: boolean
|
hideDetails?: boolean
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
forceOpen?: boolean
|
forceOpen?: boolean
|
||||||
defer?: boolean
|
defer?: boolean
|
||||||
locked?: boolean
|
locked?: boolean
|
||||||
watchDetails?: boolean
|
animated?: boolean
|
||||||
springContent?: boolean
|
|
||||||
onSubtitleClick?: () => void
|
onSubtitleClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolCallTriggerBody(props: {
|
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
|
||||||
trigger: TriggerTitle | JSX.Element
|
|
||||||
pending: boolean
|
|
||||||
onSubtitleClick?: () => void
|
|
||||||
arrow?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div data-component="tool-trigger" data-arrow={props.arrow ? "" : undefined}>
|
|
||||||
<div data-slot="basic-tool-tool-trigger-content">
|
|
||||||
<div data-slot="basic-tool-tool-info">
|
|
||||||
<Switch>
|
|
||||||
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
|
||||||
{(trigger) => (
|
|
||||||
<div data-slot="basic-tool-tool-info-structured">
|
|
||||||
<div data-slot="basic-tool-tool-info-main">
|
|
||||||
<span
|
|
||||||
data-slot="basic-tool-tool-title"
|
|
||||||
classList={{
|
|
||||||
[trigger().titleClass ?? ""]: !!trigger().titleClass,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextShimmer text={trigger().title} active={props.pending} />
|
|
||||||
</span>
|
|
||||||
<Show when={!props.pending}>
|
|
||||||
<Show when={trigger().subtitle}>
|
|
||||||
<span
|
|
||||||
data-slot="basic-tool-tool-subtitle"
|
|
||||||
classList={{
|
|
||||||
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
|
|
||||||
clickable: !!props.onSubtitleClick,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (!props.onSubtitleClick) return
|
|
||||||
e.stopPropagation()
|
|
||||||
props.onSubtitleClick()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{trigger().subtitle}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={trigger().args?.length}>
|
|
||||||
<For each={trigger().args}>
|
|
||||||
{(arg) => (
|
|
||||||
<span
|
|
||||||
data-slot="basic-tool-tool-arg"
|
|
||||||
classList={{
|
|
||||||
[trigger().argsClass ?? ""]: !!trigger().argsClass,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{arg}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<Show when={!props.pending && trigger().action}>{trigger().action}</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>{props.trigger as JSX.Element}</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={props.arrow}>
|
|
||||||
<Collapsible.Arrow />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolCallPanel(props: ToolCallPanelBaseProps) {
|
export function BasicTool(props: BasicToolProps) {
|
||||||
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
|
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
|
||||||
const [ready, setReady] = createSignal(open())
|
const [ready, setReady] = createSignal(open())
|
||||||
const pendingRaw = () => props.status === "pending" || props.status === "running"
|
const pending = () => props.status === "pending" || props.status === "running"
|
||||||
const pending = hold(pendingRaw, 1000)
|
|
||||||
const watchDetails = () => props.watchDetails !== false
|
|
||||||
|
|
||||||
let frame: number | undefined
|
let frame: number | undefined
|
||||||
|
|
||||||
@@ -144,7 +59,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
|
|||||||
on(
|
on(
|
||||||
open,
|
open,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (!props.defer || props.springContent) return
|
if (!props.defer) return
|
||||||
if (!value) {
|
if (!value) {
|
||||||
cancel()
|
cancel()
|
||||||
setReady(false)
|
setReady(false)
|
||||||
@@ -162,110 +77,36 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Animated content height — single springValue drives all height changes
|
// Animated height for collapsible open/close
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
let bodyRef: HTMLDivElement | undefined
|
let heightAnim: AnimationPlaybackControls | undefined
|
||||||
let fadeAnim: AnimationPlaybackControls | undefined
|
|
||||||
let observer: ResizeObserver | undefined
|
|
||||||
let resizeFrame: number | undefined
|
|
||||||
const initialOpen = open()
|
const initialOpen = open()
|
||||||
const heightSpring = tunableSpringValue<number>(0, COLLAPSIBLE_SPRING)
|
|
||||||
|
|
||||||
const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0))
|
|
||||||
|
|
||||||
const doOpen = () => {
|
|
||||||
if (!contentRef || !bodyRef) return
|
|
||||||
contentRef.style.display = ""
|
|
||||||
// Ensure fade starts from 0 if content was hidden (first open or after close cleared styles)
|
|
||||||
if (bodyRef.style.opacity === "") {
|
|
||||||
bodyRef.style.opacity = "0"
|
|
||||||
bodyRef.style.filter = "blur(2px)"
|
|
||||||
}
|
|
||||||
const next = read()
|
|
||||||
fadeAnim?.stop()
|
|
||||||
fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_SPRING)
|
|
||||||
fadeAnim.finished.then(() => {
|
|
||||||
if (!bodyRef) return
|
|
||||||
bodyRef.style.opacity = ""
|
|
||||||
bodyRef.style.filter = ""
|
|
||||||
})
|
|
||||||
heightSpring.set(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const doClose = () => {
|
|
||||||
if (!contentRef || !bodyRef) return
|
|
||||||
fadeAnim?.stop()
|
|
||||||
fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_SPRING)
|
|
||||||
fadeAnim.finished.then(() => {
|
|
||||||
if (!contentRef || open()) return
|
|
||||||
contentRef.style.display = "none"
|
|
||||||
})
|
|
||||||
heightSpring.set(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const grow = () => {
|
|
||||||
if (!contentRef || !open()) return
|
|
||||||
const next = read()
|
|
||||||
if (Math.abs(next - heightSpring.get()) < 1) return
|
|
||||||
heightSpring.set(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (!props.springContent || props.animate === false || !contentRef || !bodyRef) return
|
|
||||||
|
|
||||||
const offChange = heightSpring.on("change", (v) => {
|
|
||||||
if (!contentRef) return
|
|
||||||
contentRef.style.height = `${Math.max(0, Math.ceil(v))}px`
|
|
||||||
})
|
|
||||||
onCleanup(() => {
|
|
||||||
offChange()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (watchDetails()) {
|
|
||||||
observer = new ResizeObserver(() => {
|
|
||||||
if (resizeFrame !== undefined) return
|
|
||||||
resizeFrame = requestAnimationFrame(() => {
|
|
||||||
resizeFrame = undefined
|
|
||||||
grow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
observer.observe(bodyRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open()) return
|
|
||||||
if (contentRef.style.display !== "none") {
|
|
||||||
const next = read()
|
|
||||||
heightSpring.jump(next)
|
|
||||||
contentRef.style.height = `${next}px`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let mountFrame: number | undefined = requestAnimationFrame(() => {
|
|
||||||
mountFrame = undefined
|
|
||||||
if (!open()) return
|
|
||||||
doOpen()
|
|
||||||
})
|
|
||||||
onCleanup(() => {
|
|
||||||
if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
open,
|
open,
|
||||||
(isOpen) => {
|
(isOpen) => {
|
||||||
if (!props.springContent || props.animate === false || !contentRef) return
|
if (!props.animated || !contentRef) return
|
||||||
if (isOpen) doOpen()
|
heightAnim?.stop()
|
||||||
else doClose()
|
if (isOpen) {
|
||||||
|
contentRef.style.overflow = "hidden"
|
||||||
|
heightAnim = animate(contentRef, { height: "auto" }, SPRING)
|
||||||
|
heightAnim.finished.then(() => {
|
||||||
|
if (!contentRef || !open()) return
|
||||||
|
contentRef.style.overflow = "visible"
|
||||||
|
contentRef.style.height = "auto"
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
contentRef.style.overflow = "hidden"
|
||||||
|
heightAnim = animate(contentRef, { height: "0px" }, SPRING)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
heightAnim?.stop()
|
||||||
observer?.disconnect()
|
|
||||||
fadeAnim?.stop()
|
|
||||||
heightSpring.destroy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
@@ -277,34 +118,85 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
|
|||||||
return (
|
return (
|
||||||
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
|
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<ToolCallTriggerBody
|
<div data-component="tool-trigger">
|
||||||
trigger={props.trigger}
|
<div data-slot="basic-tool-tool-trigger-content">
|
||||||
pending={pending()}
|
<div data-slot="basic-tool-tool-info">
|
||||||
onSubtitleClick={props.onSubtitleClick}
|
<Switch>
|
||||||
arrow={!!props.children && !props.hideDetails && !props.locked && !pending()}
|
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
||||||
/>
|
{(trigger) => (
|
||||||
|
<div data-slot="basic-tool-tool-info-structured">
|
||||||
|
<div data-slot="basic-tool-tool-info-main">
|
||||||
|
<span
|
||||||
|
data-slot="basic-tool-tool-title"
|
||||||
|
classList={{
|
||||||
|
[trigger().titleClass ?? ""]: !!trigger().titleClass,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextShimmer text={trigger().title} active={pending()} />
|
||||||
|
</span>
|
||||||
|
<Show when={!pending()}>
|
||||||
|
<Show when={trigger().subtitle}>
|
||||||
|
<span
|
||||||
|
data-slot="basic-tool-tool-subtitle"
|
||||||
|
classList={{
|
||||||
|
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
|
||||||
|
clickable: !!props.onSubtitleClick,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (props.onSubtitleClick) {
|
||||||
|
e.stopPropagation()
|
||||||
|
props.onSubtitleClick()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{trigger().subtitle}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={trigger().args?.length}>
|
||||||
|
<For each={trigger().args}>
|
||||||
|
{(arg) => (
|
||||||
|
<span
|
||||||
|
data-slot="basic-tool-tool-arg"
|
||||||
|
classList={{
|
||||||
|
[trigger().argsClass ?? ""]: !!trigger().argsClass,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{arg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={!pending() && trigger().action}>{trigger().action}</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>{props.trigger as JSX.Element}</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
|
||||||
|
<Collapsible.Arrow />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<Show when={props.springContent && props.animate !== false && props.children && !props.hideDetails}>
|
<Show when={props.animated && props.children && !props.hideDetails}>
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
data-slot="collapsible-content"
|
data-slot="collapsible-content"
|
||||||
data-spring-content
|
data-animated
|
||||||
style={{
|
style={{
|
||||||
height: initialOpen ? "auto" : "0px",
|
height: initialOpen ? "auto" : "0px",
|
||||||
overflow: "hidden",
|
overflow: initialOpen ? "visible" : "hidden",
|
||||||
display: initialOpen ? undefined : "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div ref={bodyRef} data-slot="basic-tool-content-inner">
|
{props.children}
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={(!props.springContent || props.animate === false) && props.children && !props.hideDetails}>
|
<Show when={!props.animated && props.children && !props.hideDetails}>
|
||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
<Show when={!props.defer || ready()}>
|
<Show when={!props.defer || ready()}>{props.children}</Show>
|
||||||
<div data-slot="basic-tool-content-inner">{props.children}</div>
|
|
||||||
</Show>
|
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Show>
|
</Show>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
@@ -330,60 +222,6 @@ function args(input: Record<string, unknown> | undefined) {
|
|||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCallRowProps {
|
|
||||||
variant: "row"
|
|
||||||
icon: string
|
|
||||||
trigger: TriggerTitle | JSX.Element
|
|
||||||
status?: string
|
|
||||||
animate?: boolean
|
|
||||||
onSubtitleClick?: () => void
|
|
||||||
open?: boolean
|
|
||||||
showArrow?: boolean
|
|
||||||
onOpenChange?: (value: boolean) => void
|
|
||||||
}
|
|
||||||
export interface ToolCallPanelProps extends Omit<ToolCallPanelBaseProps, "hideDetails"> {
|
|
||||||
variant: "panel"
|
|
||||||
}
|
|
||||||
export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps
|
|
||||||
function ToolCallRoot(props: ToolCallProps) {
|
|
||||||
const pending = () => props.status === "pending" || props.status === "running"
|
|
||||||
if (props.variant === "row") {
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={props.onOpenChange}
|
|
||||||
fallback={
|
|
||||||
<div data-component="collapsible" data-variant="normal" class="tool-collapsible">
|
|
||||||
<div data-slot="collapsible-trigger">
|
|
||||||
<ToolCallTriggerBody
|
|
||||||
trigger={props.trigger}
|
|
||||||
pending={pending()}
|
|
||||||
onSubtitleClick={props.onSubtitleClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(onOpenChange) => (
|
|
||||||
<Collapsible open={props.open ?? true} onOpenChange={onOpenChange()} class="tool-collapsible">
|
|
||||||
<Collapsible.Trigger>
|
|
||||||
<ToolCallTriggerBody
|
|
||||||
trigger={props.trigger}
|
|
||||||
pending={pending()}
|
|
||||||
onSubtitleClick={props.onSubtitleClick}
|
|
||||||
arrow={!!props.showArrow}
|
|
||||||
/>
|
|
||||||
</Collapsible.Trigger>
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, rest] = splitProps(props, ["variant"])
|
|
||||||
return <ToolCallPanel {...rest} />
|
|
||||||
}
|
|
||||||
export const ToolCall = ToolCallRoot
|
|
||||||
|
|
||||||
export function GenericTool(props: {
|
export function GenericTool(props: {
|
||||||
tool: string
|
tool: string
|
||||||
status?: string
|
status?: string
|
||||||
@@ -391,8 +229,7 @@ export function GenericTool(props: {
|
|||||||
input?: Record<string, unknown>
|
input?: Record<string, unknown>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ToolCall
|
<BasicTool
|
||||||
variant={props.hideDetails ? "row" : "panel"}
|
|
||||||
icon="mcp"
|
icon="mcp"
|
||||||
status={props.status}
|
status={props.status}
|
||||||
trigger={{
|
trigger={{
|
||||||
@@ -400,6 +237,7 @@ export function GenericTool(props: {
|
|||||||
subtitle: label(props.input),
|
subtitle: label(props.input),
|
||||||
args: args(props.input),
|
args: args(props.input),
|
||||||
}}
|
}}
|
||||||
|
hideDetails={props.hideDetails}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,14 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|
||||||
&.tool-collapsible [data-slot="collapsible-trigger"] {
|
&.tool-collapsible {
|
||||||
height: 37px;
|
gap: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
&.tool-collapsible [data-slot="basic-tool-content-inner"] {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="collapsible-trigger"] {
|
[data-slot="collapsible-trigger"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 36px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
@@ -27,17 +23,6 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
color: var(--text-base);
|
color: var(--text-base);
|
||||||
|
|
||||||
> [data-component="tool-trigger"][data-arrow] {
|
|
||||||
width: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
flex: 0 1 auto;
|
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-trigger-content"] {
|
|
||||||
width: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="collapsible-arrow"] {
|
[data-slot="collapsible-arrow"] {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
@@ -65,6 +50,9 @@
|
|||||||
line-height: var(--line-height-large); /* 166.667% */
|
line-height: var(--line-height-large); /* 166.667% */
|
||||||
letter-spacing: var(--letter-spacing-normal);
|
letter-spacing: var(--letter-spacing-normal);
|
||||||
|
|
||||||
|
/* &:hover { */
|
||||||
|
/* background-color: var(--surface-base); */
|
||||||
|
/* } */
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
background-color: var(--surface-raised-base-hover);
|
background-color: var(--surface-raised-base-hover);
|
||||||
@@ -94,16 +82,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="collapsible-content"] {
|
[data-slot="collapsible-content"] {
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
|
/* animation: slideUp 250ms ease-out; */
|
||||||
|
|
||||||
&[data-expanded] {
|
&[data-expanded] {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* JS-animated content: overflow managed by animate() */
|
/* &[data-expanded] { */
|
||||||
&[data-spring-content] {
|
/* animation: slideDown 250ms ease-out; */
|
||||||
overflow: clip;
|
/* } */
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-variant="ghost"] {
|
&[data-variant="ghost"] {
|
||||||
@@ -115,6 +103,9 @@
|
|||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
|
/* &:hover { */
|
||||||
|
/* color: var(--text-strong); */
|
||||||
|
/* } */
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
background-color: var(--surface-raised-base-hover);
|
background-color: var(--surface-raised-base-hover);
|
||||||
@@ -131,3 +122,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--kb-collapsible-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
height: var(--kb-collapsible-content-height);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
import { createMemo, createSignal, For, onMount } from "solid-js"
|
|
||||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
|
||||||
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
import { useI18n } from "../context/i18n"
|
|
||||||
import { ToolCall } from "./basic-tool"
|
|
||||||
import { ToolStatusTitle } from "./tool-status-title"
|
|
||||||
import { AnimatedCountList } from "./tool-count-summary"
|
|
||||||
import { RollingResults } from "./rolling-results"
|
|
||||||
import { GROW_SPRING } from "./motion"
|
|
||||||
import { useSpring } from "./motion-spring"
|
|
||||||
import { busy, updateScrollMask, useCollapsible, useRowWipe } from "./tool-utils"
|
|
||||||
|
|
||||||
function contextToolLabel(part: ToolPart): { action: string; detail: string } {
|
|
||||||
const state = part.state
|
|
||||||
const title = "title" in state ? (state.title as string | undefined) : undefined
|
|
||||||
const input = state.input
|
|
||||||
if (part.tool === "read") {
|
|
||||||
const path = input?.filePath as string | undefined
|
|
||||||
return { action: "Read", detail: title || (path ? getFilename(path) : "") }
|
|
||||||
}
|
|
||||||
if (part.tool === "grep") {
|
|
||||||
const pattern = input?.pattern as string | undefined
|
|
||||||
return { action: "Search", detail: title || (pattern ? `"${pattern}"` : "") }
|
|
||||||
}
|
|
||||||
if (part.tool === "glob") {
|
|
||||||
const pattern = input?.pattern as string | undefined
|
|
||||||
return { action: "Find", detail: title || (pattern ?? "") }
|
|
||||||
}
|
|
||||||
if (part.tool === "list") {
|
|
||||||
const path = input?.path as string | undefined
|
|
||||||
return { action: "List", detail: title || (path ? getFilename(path) : "") }
|
|
||||||
}
|
|
||||||
return { action: part.tool, detail: title || "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
function contextToolSummary(parts: ToolPart[]) {
|
|
||||||
let read = 0
|
|
||||||
let search = 0
|
|
||||||
let list = 0
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.tool === "read") read++
|
|
||||||
else if (part.tool === "glob" || part.tool === "grep") search++
|
|
||||||
else if (part.tool === "list") list++
|
|
||||||
}
|
|
||||||
return { read, search, list }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContextToolGroupHeader(props: {
|
|
||||||
parts: ToolPart[]
|
|
||||||
pending: boolean
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (value: boolean) => void
|
|
||||||
}) {
|
|
||||||
const i18n = useI18n()
|
|
||||||
const summary = createMemo(() => contextToolSummary(props.parts))
|
|
||||||
return (
|
|
||||||
<ToolCall
|
|
||||||
variant="row"
|
|
||||||
icon="magnifying-glass-menu"
|
|
||||||
open={props.open}
|
|
||||||
showArrow
|
|
||||||
onOpenChange={props.onOpenChange}
|
|
||||||
trigger={
|
|
||||||
<div data-component="context-tool-group-trigger" data-pending={props.pending || undefined}>
|
|
||||||
<span
|
|
||||||
data-slot="context-tool-group-title"
|
|
||||||
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
|
|
||||||
>
|
|
||||||
<span data-slot="context-tool-group-label" class="shrink-0">
|
|
||||||
<ToolStatusTitle
|
|
||||||
active={props.pending}
|
|
||||||
activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
|
|
||||||
doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
|
|
||||||
split={false}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
data-slot="context-tool-group-summary"
|
|
||||||
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
|
|
||||||
>
|
|
||||||
<AnimatedCountList
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: "read",
|
|
||||||
count: summary().read,
|
|
||||||
one: i18n.t("ui.messagePart.context.read.one"),
|
|
||||||
other: i18n.t("ui.messagePart.context.read.other"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "search",
|
|
||||||
count: summary().search,
|
|
||||||
one: i18n.t("ui.messagePart.context.search.one"),
|
|
||||||
other: i18n.t("ui.messagePart.context.search.other"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "list",
|
|
||||||
count: summary().list,
|
|
||||||
one: i18n.t("ui.messagePart.context.list.one"),
|
|
||||||
other: i18n.t("ui.messagePart.context.list.other"),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
fallback=""
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) {
|
|
||||||
let contentRef: HTMLDivElement | undefined
|
|
||||||
let bodyRef: HTMLDivElement | undefined
|
|
||||||
let scrollRef: HTMLDivElement | undefined
|
|
||||||
const updateMask = () => {
|
|
||||||
if (scrollRef) updateScrollMask(scrollRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
useCollapsible({
|
|
||||||
content: () => contentRef,
|
|
||||||
body: () => bodyRef,
|
|
||||||
open: () => props.expanded,
|
|
||||||
onOpen: updateMask,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
|
|
||||||
<div ref={bodyRef}>
|
|
||||||
<div ref={scrollRef} data-component="context-tool-expanded-list" onScroll={updateMask}>
|
|
||||||
<For each={props.parts}>
|
|
||||||
{(part) => {
|
|
||||||
const label = createMemo(() => contextToolLabel(part))
|
|
||||||
return (
|
|
||||||
<div data-component="context-tool-expanded-row">
|
|
||||||
<span data-slot="context-tool-expanded-action">{label().action}</span>
|
|
||||||
<span data-slot="context-tool-expanded-detail">{label().detail}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
|
|
||||||
const reduce = useReducedMotion()
|
|
||||||
const wiped = new Set<string>()
|
|
||||||
const [mounted, setMounted] = createSignal(false)
|
|
||||||
onMount(() => setMounted(true))
|
|
||||||
const show = () => mounted() && props.pending
|
|
||||||
const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
|
|
||||||
const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
|
|
||||||
return (
|
|
||||||
<div style={{ opacity: reduce() ? (show() ? 1 : 0) : opacity(), filter: `blur(${reduce() ? 0 : blur()}px)` }}>
|
|
||||||
<RollingResults
|
|
||||||
items={props.parts}
|
|
||||||
rows={5}
|
|
||||||
rowHeight={22}
|
|
||||||
rowGap={0}
|
|
||||||
open={props.pending}
|
|
||||||
animate
|
|
||||||
getKey={(part) => part.callID || part.id}
|
|
||||||
render={(part) => {
|
|
||||||
const label = createMemo(() => contextToolLabel(part))
|
|
||||||
const k = part.callID || part.id
|
|
||||||
return (
|
|
||||||
<div data-component="context-tool-rolling-row">
|
|
||||||
<span data-slot="context-tool-rolling-action">{label().action}</span>
|
|
||||||
{(() => {
|
|
||||||
const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>()
|
|
||||||
useRowWipe({
|
|
||||||
id: () => k,
|
|
||||||
text: () => label().detail,
|
|
||||||
ref: detailRef,
|
|
||||||
seen: wiped,
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
ref={setDetailRef}
|
|
||||||
data-slot="context-tool-rolling-detail"
|
|
||||||
style={{ display: label().detail ? undefined : "none" }}
|
|
||||||
>
|
|
||||||
{label().detail}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,432 +0,0 @@
|
|||||||
import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
|
|
||||||
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
|
|
||||||
|
|
||||||
export interface GrowBoxProps {
|
|
||||||
children: JSX.Element
|
|
||||||
/** Enable animation. When false, content shows immediately at full height. */
|
|
||||||
animate?: boolean
|
|
||||||
/** Animate height from 0 to content height. Default: true. */
|
|
||||||
grow?: boolean
|
|
||||||
/** Keep watching body size and animate subsequent height changes. Default: false. */
|
|
||||||
watch?: boolean
|
|
||||||
/** Fade in body content (opacity + blur). Default: true. */
|
|
||||||
fade?: boolean
|
|
||||||
/** Top padding in px on the body wrapper. Default: 0. */
|
|
||||||
gap?: number
|
|
||||||
/** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */
|
|
||||||
autoHeight?: boolean
|
|
||||||
/** Controlled visibility for animating open/close without unmounting children. */
|
|
||||||
open?: boolean
|
|
||||||
/** Animate controlled open/close changes after mount. Default: true. */
|
|
||||||
animateToggle?: boolean
|
|
||||||
/** data-slot attribute on the root div. */
|
|
||||||
slot?: string
|
|
||||||
/** CSS class on the root div. */
|
|
||||||
class?: string
|
|
||||||
/** Override mount and resize spring config. Default: GROW_SPRING. */
|
|
||||||
spring?: SpringConfig
|
|
||||||
/** Override controlled open/close spring config. Default: spring. */
|
|
||||||
toggleSpring?: SpringConfig
|
|
||||||
/** Show a temporary bottom edge fade while height animation is running. */
|
|
||||||
edge?: boolean
|
|
||||||
/** Edge fade height in px. Default: 20. */
|
|
||||||
edgeHeight?: number
|
|
||||||
/** Edge fade opacity (0-1). Default: 1. */
|
|
||||||
edgeOpacity?: number
|
|
||||||
/** Delay before edge fades out after height settles. Default: 320. */
|
|
||||||
edgeIdle?: number
|
|
||||||
/** Edge fade-out duration in seconds. Default: 0.24. */
|
|
||||||
edgeFade?: number
|
|
||||||
/** Edge fade-in duration in seconds. Default: 0.2. */
|
|
||||||
edgeRise?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps children in a container that animates from zero height on mount.
|
|
||||||
*
|
|
||||||
* Includes a ResizeObserver so content changes after mount are also spring-animated.
|
|
||||||
* Used for timeline turns, assistant part groups, and user messages.
|
|
||||||
*/
|
|
||||||
export function GrowBox(props: GrowBoxProps) {
|
|
||||||
const reduce = useReducedMotion()
|
|
||||||
const spring = () => props.spring ?? GROW_SPRING
|
|
||||||
const toggleSpring = () => props.toggleSpring ?? spring()
|
|
||||||
let mode: "mount" | "toggle" = "mount"
|
|
||||||
let root: HTMLDivElement | undefined
|
|
||||||
let body: HTMLDivElement | undefined
|
|
||||||
let fadeAnim: AnimationPlaybackControls | undefined
|
|
||||||
let edgeRef: HTMLDivElement | undefined
|
|
||||||
let edgeAnim: AnimationPlaybackControls | undefined
|
|
||||||
let edgeTimer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
let edgeOn = false
|
|
||||||
let mountFrame: number | undefined
|
|
||||||
let resizeFrame: number | undefined
|
|
||||||
let observer: ResizeObserver | undefined
|
|
||||||
let springTarget = -1
|
|
||||||
const height = tunableSpringValue<number>(0, {
|
|
||||||
type: "spring",
|
|
||||||
get visualDuration() {
|
|
||||||
return (mode === "toggle" ? toggleSpring() : spring()).visualDuration
|
|
||||||
},
|
|
||||||
get bounce() {
|
|
||||||
return (mode === "toggle" ? toggleSpring() : spring()).bounce
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const gap = () => Math.max(0, props.gap ?? 0)
|
|
||||||
const grow = () => props.grow !== false
|
|
||||||
const watch = () => props.watch === true
|
|
||||||
const open = () => props.open !== false
|
|
||||||
const animateToggle = () => props.animateToggle !== false
|
|
||||||
const edge = () => props.edge === true
|
|
||||||
const edgeHeight = () => Math.max(0, props.edgeHeight ?? 20)
|
|
||||||
const edgeOpacity = () => Math.min(1, Math.max(0, props.edgeOpacity ?? 1))
|
|
||||||
const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320)
|
|
||||||
const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24)
|
|
||||||
const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2)
|
|
||||||
const animated = () => props.animate !== false && !reduce()
|
|
||||||
const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0
|
|
||||||
|
|
||||||
const stopEdgeTimer = () => {
|
|
||||||
if (edgeTimer === undefined) return
|
|
||||||
clearTimeout(edgeTimer)
|
|
||||||
edgeTimer = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideEdge = (instant = false) => {
|
|
||||||
stopEdgeTimer()
|
|
||||||
if (!edgeRef) {
|
|
||||||
edgeOn = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
edgeAnim?.stop()
|
|
||||||
edgeAnim = undefined
|
|
||||||
if (instant || reduce()) {
|
|
||||||
edgeRef.style.opacity = "0"
|
|
||||||
edgeOn = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!edgeOn) {
|
|
||||||
edgeRef.style.opacity = "0"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const current = animate(edgeRef, { opacity: 0 }, { type: "spring", visualDuration: edgeFade(), bounce: 0 })
|
|
||||||
edgeAnim = current
|
|
||||||
current.finished
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
if (edgeAnim !== current) return
|
|
||||||
edgeAnim = undefined
|
|
||||||
if (!edgeRef) return
|
|
||||||
edgeRef.style.opacity = "0"
|
|
||||||
edgeOn = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const showEdge = () => {
|
|
||||||
stopEdgeTimer()
|
|
||||||
if (!edgeRef) return
|
|
||||||
if (reduce()) {
|
|
||||||
edgeRef.style.opacity = `${edgeOpacity()}`
|
|
||||||
edgeOn = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (edgeOn && edgeAnim === undefined) {
|
|
||||||
edgeRef.style.opacity = `${edgeOpacity()}`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
edgeAnim?.stop()
|
|
||||||
edgeAnim = undefined
|
|
||||||
if (!edgeOn) edgeRef.style.opacity = "0"
|
|
||||||
const current = animate(
|
|
||||||
edgeRef,
|
|
||||||
{ opacity: edgeOpacity() },
|
|
||||||
{ type: "spring", visualDuration: edgeRise(), bounce: 0 },
|
|
||||||
)
|
|
||||||
edgeAnim = current
|
|
||||||
edgeOn = true
|
|
||||||
current.finished
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
if (edgeAnim !== current) return
|
|
||||||
edgeAnim = undefined
|
|
||||||
if (!edgeRef) return
|
|
||||||
edgeRef.style.opacity = `${edgeOpacity()}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const queueEdgeHide = () => {
|
|
||||||
stopEdgeTimer()
|
|
||||||
if (!edgeOn) return
|
|
||||||
if (edgeIdle() <= 0) {
|
|
||||||
hideEdge()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
edgeTimer = setTimeout(() => {
|
|
||||||
edgeTimer = undefined
|
|
||||||
hideEdge()
|
|
||||||
}, edgeIdle())
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideBody = () => {
|
|
||||||
if (!body) return
|
|
||||||
body.style.opacity = "0"
|
|
||||||
body.style.filter = "blur(2px)"
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearBody = () => {
|
|
||||||
if (!body) return
|
|
||||||
body.style.opacity = ""
|
|
||||||
body.style.filter = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => {
|
|
||||||
if (props.fade === false || !body) return
|
|
||||||
if (reduce()) {
|
|
||||||
clearBody()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hideBody()
|
|
||||||
fadeAnim?.stop()
|
|
||||||
fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring())
|
|
||||||
fadeAnim.finished.then(() => {
|
|
||||||
if (!body || !open()) return
|
|
||||||
clearBody()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const setInstant = (visible: boolean) => {
|
|
||||||
const next = visible ? targetHeight() : 0
|
|
||||||
springTarget = next
|
|
||||||
height.jump(next)
|
|
||||||
root!.style.height = visible ? "" : "0px"
|
|
||||||
root!.style.overflow = visible ? "" : "clip"
|
|
||||||
hideEdge(true)
|
|
||||||
if (visible || props.fade === false) clearBody()
|
|
||||||
else hideBody()
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentHeight = () => {
|
|
||||||
if (!root) return 0
|
|
||||||
const v = root.style.height
|
|
||||||
if (v && v !== "auto") {
|
|
||||||
const n = Number.parseFloat(v)
|
|
||||||
if (!Number.isNaN(n)) return n
|
|
||||||
}
|
|
||||||
return Math.max(0, root.getBoundingClientRect().height)
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0))
|
|
||||||
|
|
||||||
const setHeight = (nextMode: "mount" | "toggle" = "mount") => {
|
|
||||||
if (!root || !open()) return
|
|
||||||
const next = targetHeight()
|
|
||||||
if (reduce()) {
|
|
||||||
springTarget = next
|
|
||||||
height.jump(next)
|
|
||||||
if (props.autoHeight === false || watch()) {
|
|
||||||
root.style.height = `${next}px`
|
|
||||||
root.style.overflow = next > 0 ? "visible" : "clip"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
root.style.height = "auto"
|
|
||||||
root.style.overflow = next > 0 ? "visible" : "clip"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (next === springTarget) return
|
|
||||||
const prev = currentHeight()
|
|
||||||
if (Math.abs(next - prev) < 1) {
|
|
||||||
springTarget = next
|
|
||||||
if (props.autoHeight === false || watch()) {
|
|
||||||
root.style.height = `${next}px`
|
|
||||||
root.style.overflow = next > 0 ? "visible" : "clip"
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
root.style.overflow = "clip"
|
|
||||||
springTarget = next
|
|
||||||
mode = nextMode
|
|
||||||
height.set(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (!root || !body) return
|
|
||||||
|
|
||||||
const offChange = height.on("change", (next) => {
|
|
||||||
if (!root) return
|
|
||||||
root.style.height = `${Math.max(0, next)}px`
|
|
||||||
})
|
|
||||||
const offStart = height.on("animationStart", () => {
|
|
||||||
if (!root) return
|
|
||||||
root.style.overflow = "clip"
|
|
||||||
root.style.willChange = "height"
|
|
||||||
root.style.contain = "layout style"
|
|
||||||
if (edgeReady()) showEdge()
|
|
||||||
})
|
|
||||||
const offComplete = height.on("animationComplete", () => {
|
|
||||||
if (!root) return
|
|
||||||
root.style.willChange = ""
|
|
||||||
root.style.contain = ""
|
|
||||||
if (!open()) {
|
|
||||||
springTarget = 0
|
|
||||||
root.style.height = "0px"
|
|
||||||
root.style.overflow = "clip"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const next = targetHeight()
|
|
||||||
springTarget = next
|
|
||||||
if (props.autoHeight === false || watch()) {
|
|
||||||
root.style.height = `${next}px`
|
|
||||||
root.style.overflow = next > 0 ? "visible" : "clip"
|
|
||||||
if (edgeReady()) queueEdgeHide()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
root.style.height = "auto"
|
|
||||||
root.style.overflow = "visible"
|
|
||||||
if (edgeReady()) queueEdgeHide()
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
offComplete()
|
|
||||||
offStart()
|
|
||||||
offChange()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (watch()) {
|
|
||||||
observer = new ResizeObserver(() => {
|
|
||||||
if (!open()) return
|
|
||||||
if (resizeFrame !== undefined) return
|
|
||||||
resizeFrame = requestAnimationFrame(() => {
|
|
||||||
resizeFrame = undefined
|
|
||||||
setHeight("mount")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
observer.observe(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!animated()) {
|
|
||||||
setInstant(open())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.fade !== false) hideBody()
|
|
||||||
hideEdge(true)
|
|
||||||
|
|
||||||
if (!open()) {
|
|
||||||
root.style.height = "0px"
|
|
||||||
root.style.overflow = "clip"
|
|
||||||
} else {
|
|
||||||
if (grow()) {
|
|
||||||
root.style.height = "0px"
|
|
||||||
root.style.overflow = "clip"
|
|
||||||
} else {
|
|
||||||
root.style.height = "auto"
|
|
||||||
root.style.overflow = "visible"
|
|
||||||
}
|
|
||||||
mountFrame = requestAnimationFrame(() => {
|
|
||||||
mountFrame = undefined
|
|
||||||
fadeBodyIn("mount")
|
|
||||||
if (grow()) setHeight("mount")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => props.open,
|
|
||||||
(value) => {
|
|
||||||
if (value === undefined) return
|
|
||||||
if (!root || !body) return
|
|
||||||
if (!animateToggle() || reduce()) {
|
|
||||||
setInstant(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fadeAnim?.stop()
|
|
||||||
if (!value) hideEdge(true)
|
|
||||||
if (!value) {
|
|
||||||
const next = currentHeight()
|
|
||||||
if (Math.abs(next - height.get()) >= 1) {
|
|
||||||
springTarget = next
|
|
||||||
height.jump(next)
|
|
||||||
root.style.height = `${next}px`
|
|
||||||
}
|
|
||||||
if (props.fade !== false) {
|
|
||||||
fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring())
|
|
||||||
}
|
|
||||||
root.style.overflow = "clip"
|
|
||||||
springTarget = 0
|
|
||||||
mode = "toggle"
|
|
||||||
height.set(0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fadeBodyIn("toggle")
|
|
||||||
setHeight("toggle")
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!edgeRef) return
|
|
||||||
edgeRef.style.height = `${edgeHeight()}px`
|
|
||||||
if (!animated() || !open() || edgeHeight() <= 0) {
|
|
||||||
hideEdge(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (edge()) return
|
|
||||||
hideEdge()
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!root || !body) return
|
|
||||||
if (!reduce()) return
|
|
||||||
fadeAnim?.stop()
|
|
||||||
edgeAnim?.stop()
|
|
||||||
setInstant(open())
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
stopEdgeTimer()
|
|
||||||
if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
|
|
||||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
|
||||||
observer?.disconnect()
|
|
||||||
height.destroy()
|
|
||||||
fadeAnim?.stop()
|
|
||||||
edgeAnim?.stop()
|
|
||||||
edgeAnim = undefined
|
|
||||||
edgeOn = false
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={root}
|
|
||||||
data-slot={props.slot}
|
|
||||||
class={props.class}
|
|
||||||
style={{
|
|
||||||
transform: "translateZ(0)",
|
|
||||||
position: "relative",
|
|
||||||
height: open() ? undefined : "0px",
|
|
||||||
overflow: open() ? undefined : "clip",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={edgeRef}
|
|
||||||
data-slot="grow-box-edge"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "0",
|
|
||||||
right: "0",
|
|
||||||
bottom: "0",
|
|
||||||
height: `${edgeHeight()}px`,
|
|
||||||
opacity: 0,
|
|
||||||
"pointer-events": "none",
|
|
||||||
background: "linear-gradient(to bottom, transparent 0%, var(--background-stronger) 100%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,10 @@
|
|||||||
[data-component="assistant-message"] {
|
[data-component="assistant-message"] {
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="assistant-parts"] {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0;
|
gap: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="assistant-part-item"] {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="user-message"] {
|
[data-component="user-message"] {
|
||||||
@@ -37,14 +27,6 @@
|
|||||||
color: var(--text-weak);
|
color: var(--text-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="user-message-inner"] {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
width: 100%;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
[data-slot="user-message-attachments"] {
|
[data-slot="user-message-attachments"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -53,7 +35,6 @@
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: min(82%, 64ch);
|
max-width: min(82%, 64ch);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="user-message-attachment"] {
|
[data-slot="user-message-attachment"] {
|
||||||
@@ -153,7 +134,7 @@
|
|||||||
|
|
||||||
[data-slot="user-message-copy-wrapper"] {
|
[data-slot="user-message-copy-wrapper"] {
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
margin-top: 0;
|
margin-top: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -163,6 +144,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
will-change: opacity;
|
will-change: opacity;
|
||||||
|
|
||||||
[data-component="tooltip-trigger"] {
|
[data-component="tooltip-trigger"] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
@@ -205,21 +187,56 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-text-strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-medium {
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="text-part"] {
|
[data-component="text-part"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 0;
|
margin-top: 24px;
|
||||||
padding-block: 4px;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
[data-slot="text-part-body"] {
|
[data-slot="text-part-body"] {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="text-part-turn-summary"] {
|
[data-slot="text-part-copy-wrapper"] {
|
||||||
|
min-height: 24px;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
will-change: opacity;
|
||||||
|
|
||||||
|
[data-component="tooltip-trigger"] {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="text-part-meta"] {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="text-part-copy-wrapper"][data-interrupted] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover [data-slot="text-part-copy-wrapper"],
|
||||||
|
&:focus-within [data-slot="text-part-copy-wrapper"] {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="markdown"] {
|
[data-component="markdown"] {
|
||||||
@@ -228,10 +245,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="assistant-part-item"][data-kind="text"][data-last="true"] [data-component="text-part"] {
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="compaction-part"] {
|
[data-component="compaction-part"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -265,6 +278,7 @@
|
|||||||
line-height: var(--line-height-normal);
|
line-height: var(--line-height-normal);
|
||||||
|
|
||||||
[data-component="markdown"] {
|
[data-component="markdown"] {
|
||||||
|
margin-top: 24px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
color: var(--text-weak);
|
color: var(--text-weak);
|
||||||
@@ -358,16 +372,13 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
|
|
||||||
mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
|
|
||||||
-webkit-mask-repeat: no-repeat;
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="markdown"] {
|
[data-component="markdown"] {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
@@ -437,7 +448,7 @@
|
|||||||
[data-component="write-trigger"] {
|
[data-component="write-trigger"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@@ -450,8 +461,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="message-part-title"] {
|
[data-slot="message-part-title"] {
|
||||||
flex-shrink: 1;
|
flex-shrink: 0;
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -483,45 +493,40 @@
|
|||||||
[data-slot="message-part-title-text"] {
|
[data-slot="message-part-title-text"] {
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="message-part-meta-line"],
|
|
||||||
.message-part-meta-line {
|
|
||||||
min-width: 0;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-weight: var(--font-weight-regular);
|
|
||||||
|
|
||||||
[data-component="diff-changes"] {
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-part-meta-line.soft {
|
|
||||||
[data-slot="message-part-title-filename"] {
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="message-part-title-filename"] {
|
[data-slot="message-part-title-filename"] {
|
||||||
/* No text-transform - preserve original filename casing */
|
/* No text-transform - preserve original filename casing */
|
||||||
color: var(--text-strong);
|
font-weight: var(--font-weight-regular);
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="message-part-directory-inline"] {
|
[data-slot="message-part-path"] {
|
||||||
color: var(--text-weak);
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: min(48vw, 36ch);
|
font-weight: var(--font-weight-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="message-part-directory"] {
|
||||||
|
color: var(--text-weak);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="message-part-filename"] {
|
||||||
|
color: var(--text-strong);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="message-part-actions"] {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="edit-content"] {
|
[data-component="edit-content"] {
|
||||||
@@ -612,17 +617,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="webfetch-meta"] {
|
|
||||||
min-width: 0;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
[data-component="tool-action"] {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="todos"] {
|
[data-component="todos"] {
|
||||||
padding: 10px 0 24px 0;
|
padding: 10px 0 24px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -645,6 +639,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-component="context-tool-group-trigger"] {
|
[data-component="context-tool-group-trigger"] {
|
||||||
|
width: 100%;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -652,352 +647,28 @@
|
|||||||
gap: 0px;
|
gap: 0px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&[data-pending] {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="context-tool-group-title"] {
|
[data-slot="context-tool-group-title"] {
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent the trigger content from stretching full-width so the arrow sits after the text */
|
[data-slot="collapsible-arrow"] {
|
||||||
[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) {
|
|
||||||
width: auto;
|
|
||||||
flex: 0 1 auto;
|
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-info"] {
|
|
||||||
flex: 0 1 auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="context-tool-step"] {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
padding-left: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="context-tool-expanded-list"] {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 4px 0 4px 12px;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
-webkit-mask-repeat: no-repeat;
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="context-tool-expanded-row"] {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
height: 22px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
[data-slot="context-tool-expanded-action"] {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="context-tool-expanded-detail"] {
|
|
||||||
flex-shrink: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
color: var(--text-base);
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="context-tool-rolling-row"] {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
padding-left: 12px;
|
|
||||||
|
|
||||||
[data-slot="context-tool-rolling-action"] {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="context-tool-rolling-detail"] {
|
|
||||||
flex-shrink: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="shell-rolling-results"] {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
[data-slot="shell-rolling-header-clip"] {
|
|
||||||
&:hover [data-slot="shell-rolling-actions"] {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-clickable="true"] {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-rolling-header"] {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
height: 37px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-rolling-title"] {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-family: var(--font-family-sans);
|
|
||||||
font-size: 14px;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
line-height: var(--line-height-large);
|
|
||||||
letter-spacing: var(--letter-spacing-normal);
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-rolling-subtitle"] {
|
|
||||||
flex: 0 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-family: var(--font-family-sans);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: var(--font-weight-normal);
|
|
||||||
line-height: var(--line-height-large);
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-rolling-actions"] {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell-rolling-copy {
|
|
||||||
border: none !important;
|
|
||||||
outline: none !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
background: transparent !important;
|
|
||||||
|
|
||||||
[data-slot="icon-svg"] {
|
|
||||||
color: var(--icon-weaker);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
|
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
|
|
||||||
[data-slot="icon-svg"] {
|
|
||||||
color: var(--icon-base);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-rolling-arrow"] {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--icon-weaker);
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
transition: transform 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-rolling-arrow"][data-open="true"] {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="shell-rolling-output"] {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-rolling-preview"] {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="shell-expanded-output"] {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="shell-expanded-shell"] {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
border: 1px solid var(--border-weak-base);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-expanded-body"] {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-expanded-top"] {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 9px 44px 9px 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-expanded-command"] {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
font-family: var(--font-family-mono);
|
|
||||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-expanded-prompt"] {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--text-weaker);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-expanded-input"] {
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--text-strong);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-expanded-actions"] {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 8px;
|
|
||||||
z-index: 1;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell-expanded-copy {
|
|
||||||
border: none !important;
|
|
||||||
outline: none !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
background: transparent !important;
|
|
||||||
|
|
||||||
[data-slot="icon-svg"] {
|
|
||||||
color: var(--icon-weaker);
|
color: var(--icon-weaker);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
[data-component="context-tool-group-list"] {
|
||||||
background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
|
padding: 6px 0 4px 0;
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
|
display: flex;
|
||||||
border-radius: var(--radius-sm);
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
[data-slot="icon-svg"] {
|
[data-slot="context-tool-group-item"] {
|
||||||
color: var(--icon-base);
|
min-width: 0;
|
||||||
}
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="shell-expanded-divider"] {
|
|
||||||
width: 100%;
|
|
||||||
height: 1px;
|
|
||||||
background: var(--border-weak-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-expanded-pre"] {
|
|
||||||
margin: 0;
|
|
||||||
padding: 12px 16px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-family: var(--font-family-mono);
|
|
||||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="shell-rolling-command"],
|
|
||||||
[data-component="shell-rolling-row"] {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: pre;
|
|
||||||
padding-left: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="shell-rolling-text"] {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-family: var(--font-family-mono);
|
|
||||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
|
||||||
font-size: var(--font-size-small);
|
|
||||||
line-height: var(--line-height-large);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] {
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] {
|
|
||||||
color: var(--text-weaker);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] {
|
|
||||||
color: var(--text-weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="diagnostics"] {
|
[data-component="diagnostics"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1058,30 +729,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="assistant-part-grow"] {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="tool-part-wrapper"][data-tool="bash"] {
|
|
||||||
[data-component="tool-trigger"] {
|
|
||||||
width: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-info-main"] {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-title"],
|
|
||||||
[data-slot="basic-tool-tool-subtitle"] {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
line-height: var(--line-height-large);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="dock-prompt"][data-kind="permission"] {
|
[data-component="dock-prompt"][data-kind="permission"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1540,7 +1187,8 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: var(--sticky-accordion-top, 0px);
|
top: var(--sticky-accordion-top, 0px);
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
height: 37px;
|
height: 40px;
|
||||||
|
padding-bottom: 8px;
|
||||||
background-color: var(--background-stronger);
|
background-color: var(--background-stronger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1551,12 +1199,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="apply-patch-trigger-content"] {
|
[data-slot="apply-patch-trigger-content"] {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: space-between;
|
||||||
max-width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
gap: 20px;
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="apply-patch-file-info"] {
|
[data-slot="apply-patch-file-info"] {
|
||||||
@@ -1590,9 +1237,9 @@
|
|||||||
[data-slot="apply-patch-trigger-actions"] {
|
[data-slot="apply-patch-trigger-actions"] {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="apply-patch-change"] {
|
[data-slot="apply-patch-change"] {
|
||||||
@@ -1632,11 +1279,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-component="tool-loaded-file"] {
|
[data-component="tool-loaded-file"] {
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px 0 4px 12px;
|
padding: 4px 0 4px 28px;
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
@@ -1647,11 +1293,4 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--icon-weak);
|
color: var(--icon-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,8 @@
|
|||||||
import { attachSpring, motionValue } from "motion"
|
import { attachSpring, motionValue } from "motion"
|
||||||
import type { SpringOptions } from "motion"
|
import type { SpringOptions } from "motion"
|
||||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
|
|
||||||
type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
|
type Opt = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">>
|
||||||
const eq = (a: Opt | undefined, b: Opt | undefined) =>
|
const eq = (a: Opt | undefined, b: Opt | undefined) =>
|
||||||
a?.visualDuration === b?.visualDuration &&
|
a?.visualDuration === b?.visualDuration &&
|
||||||
a?.bounce === b?.bounce &&
|
a?.bounce === b?.bounce &&
|
||||||
@@ -14,41 +13,24 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
|
|||||||
|
|
||||||
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
|
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
|
||||||
const read = () => (typeof options === "function" ? options() : options)
|
const read = () => (typeof options === "function" ? options() : options)
|
||||||
const reduce = useReducedMotion()
|
|
||||||
const [value, setValue] = createSignal(target())
|
const [value, setValue] = createSignal(target())
|
||||||
const source = motionValue(value())
|
const source = motionValue(value())
|
||||||
const spring = motionValue(value())
|
const spring = motionValue(value())
|
||||||
let config = read()
|
let config = read()
|
||||||
let reduced = reduce()
|
let stop = attachSpring(spring, source, config)
|
||||||
let stop = reduced ? () => {} : attachSpring(spring, source, config)
|
let off = spring.on("change", (next: number) => setValue(next))
|
||||||
let off = spring.on("change", (next) => setValue(next))
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const next = target()
|
source.set(target())
|
||||||
if (reduced) {
|
|
||||||
source.set(next)
|
|
||||||
spring.set(next)
|
|
||||||
setValue(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source.set(next)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
if (!options) return
|
||||||
const next = read()
|
const next = read()
|
||||||
const skip = reduce()
|
if (eq(config, next)) return
|
||||||
if (eq(config, next) && reduced === skip) return
|
|
||||||
config = next
|
config = next
|
||||||
reduced = skip
|
|
||||||
stop()
|
stop()
|
||||||
stop = skip ? () => {} : attachSpring(spring, source, next)
|
stop = attachSpring(spring, source, next)
|
||||||
if (skip) {
|
|
||||||
const value = target()
|
|
||||||
source.set(value)
|
|
||||||
spring.set(value)
|
|
||||||
setValue(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setValue(spring.get())
|
setValue(spring.get())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { followValue } from "motion"
|
|
||||||
import type { MotionValue } from "motion"
|
|
||||||
|
|
||||||
export { animate, springValue } from "motion"
|
|
||||||
export type { AnimationPlaybackControls } from "motion"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Like `springValue` but preserves getters on the config object.
|
|
||||||
* `springValue` spreads config at creation, snapshotting getter values.
|
|
||||||
* This passes the config through to `followValue` intact, so getters
|
|
||||||
* on `visualDuration` etc. fire on every `.set()` call.
|
|
||||||
*/
|
|
||||||
export function tunableSpringValue<T extends string | number>(initial: T, config: SpringConfig): MotionValue<T> {
|
|
||||||
return followValue(initial, config as any)
|
|
||||||
}
|
|
||||||
|
|
||||||
let _growDuration = 0.5
|
|
||||||
let _collapsibleDuration = 0.3
|
|
||||||
|
|
||||||
export const GROW_SPRING = {
|
|
||||||
type: "spring" as const,
|
|
||||||
get visualDuration() {
|
|
||||||
return _growDuration
|
|
||||||
},
|
|
||||||
bounce: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const COLLAPSIBLE_SPRING = {
|
|
||||||
type: "spring" as const,
|
|
||||||
get visualDuration() {
|
|
||||||
return _collapsibleDuration
|
|
||||||
},
|
|
||||||
bounce: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setGrowDuration = (v: number) => {
|
|
||||||
_growDuration = v
|
|
||||||
}
|
|
||||||
export const setCollapsibleDuration = (v: number) => {
|
|
||||||
_collapsibleDuration = v
|
|
||||||
}
|
|
||||||
export const getGrowDuration = () => _growDuration
|
|
||||||
export const getCollapsibleDuration = () => _collapsibleDuration
|
|
||||||
|
|
||||||
export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number }
|
|
||||||
|
|
||||||
export const FAST_SPRING = {
|
|
||||||
type: "spring" as const,
|
|
||||||
visualDuration: 0.35,
|
|
||||||
bounce: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GLOW_SPRING = {
|
|
||||||
type: "spring" as const,
|
|
||||||
visualDuration: 0.4,
|
|
||||||
bounce: 0.15,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WIPE_MASK =
|
|
||||||
"linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)"
|
|
||||||
|
|
||||||
export const clearMaskStyles = (el: HTMLElement) => {
|
|
||||||
el.style.maskImage = ""
|
|
||||||
el.style.webkitMaskImage = ""
|
|
||||||
el.style.maskSize = ""
|
|
||||||
el.style.webkitMaskSize = ""
|
|
||||||
el.style.maskRepeat = ""
|
|
||||||
el.style.webkitMaskRepeat = ""
|
|
||||||
el.style.maskPosition = ""
|
|
||||||
el.style.webkitMaskPosition = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export const clearFadeStyles = (el: HTMLElement) => {
|
|
||||||
el.style.opacity = ""
|
|
||||||
el.style.filter = ""
|
|
||||||
el.style.transform = ""
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
[data-component="rolling-results"] {
|
|
||||||
--rolling-results-row-height: 22px;
|
|
||||||
--rolling-results-fixed-height: var(--rolling-results-row-height);
|
|
||||||
--rolling-results-fixed-gap: 0px;
|
|
||||||
--rolling-results-row-gap: 0px;
|
|
||||||
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
[data-slot="rolling-results-viewport"] {
|
|
||||||
position: relative;
|
|
||||||
min-width: 0;
|
|
||||||
height: 0;
|
|
||||||
overflow: clip;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-overflowing="true"]:not([data-scrollable="true"]) [data-slot="rolling-results-window"] {
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0%,
|
|
||||||
black var(--rolling-results-fade),
|
|
||||||
black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
-webkit-mask-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0%,
|
|
||||||
black var(--rolling-results-fade),
|
|
||||||
black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="rolling-results-fixed"] {
|
|
||||||
min-width: 0;
|
|
||||||
height: var(--rolling-results-fixed-height);
|
|
||||||
min-height: var(--rolling-results-fixed-height);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="rolling-results-window"] {
|
|
||||||
min-width: 0;
|
|
||||||
margin-top: var(--rolling-results-fixed-gap);
|
|
||||||
height: calc(100% - var(--rolling-results-fixed-height) - var(--rolling-results-fixed-gap));
|
|
||||||
overflow: clip;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-scrollable="true"] [data-slot="rolling-results-window"] {
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-scrollable="true"] [data-slot="rolling-results-track"] {
|
|
||||||
transform: none !important;
|
|
||||||
will-change: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="rolling-results-body"] {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="rolling-results-track"] {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--rolling-results-row-gap);
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="rolling-results-row"],
|
|
||||||
[data-slot="rolling-results-empty"] {
|
|
||||||
min-width: 0;
|
|
||||||
height: var(--rolling-results-row-height);
|
|
||||||
min-height: var(--rolling-results-row-height);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="rolling-results-row"] {
|
|
||||||
color: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="rolling-results-empty"] {
|
|
||||||
color: var(--text-weaker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
|
|
||||||
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
|
|
||||||
|
|
||||||
export type RollingResultsProps<T> = {
|
|
||||||
items: T[]
|
|
||||||
render: (item: T, index: number) => JSX.Element
|
|
||||||
fixed?: JSX.Element
|
|
||||||
getKey?: (item: T, index: number) => string
|
|
||||||
rows?: number
|
|
||||||
rowHeight?: number
|
|
||||||
fixedHeight?: number
|
|
||||||
rowGap?: number
|
|
||||||
open?: boolean
|
|
||||||
scrollable?: boolean
|
|
||||||
spring?: SpringConfig
|
|
||||||
animate?: boolean
|
|
||||||
class?: string
|
|
||||||
empty?: JSX.Element
|
|
||||||
noFadeOnCollapse?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RollingResults<T>(props: RollingResultsProps<T>) {
|
|
||||||
let view: HTMLDivElement | undefined
|
|
||||||
let track: HTMLDivElement | undefined
|
|
||||||
let windowEl: HTMLDivElement | undefined
|
|
||||||
let shift: AnimationPlaybackControls | undefined
|
|
||||||
let resize: AnimationPlaybackControls | undefined
|
|
||||||
let edgeFade: AnimationPlaybackControls | undefined
|
|
||||||
const reduce = useReducedMotion()
|
|
||||||
|
|
||||||
const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3)))
|
|
||||||
const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22)))
|
|
||||||
const fixedHeight = createMemo(() => Math.max(0, Math.round(props.fixedHeight ?? rowHeight())))
|
|
||||||
const rowGap = createMemo(() => Math.max(0, Math.round(props.rowGap ?? 0)))
|
|
||||||
const fixed = createMemo(() => props.fixed !== undefined)
|
|
||||||
const list = createMemo(() => props.items ?? [])
|
|
||||||
const count = createMemo(() => list().length)
|
|
||||||
|
|
||||||
// scrollReady is the internal "transition complete" state.
|
|
||||||
// It only becomes true after props.scrollable is true AND the offset animation has settled.
|
|
||||||
const [scrollReady, setScrollReady] = createSignal(false)
|
|
||||||
|
|
||||||
const backstop = createMemo(() => Math.max(rows() * 2, 12))
|
|
||||||
const rendered = createMemo(() => {
|
|
||||||
const items = list()
|
|
||||||
if (scrollReady()) return items
|
|
||||||
const max = backstop()
|
|
||||||
return items.length > max ? items.slice(-max) : items
|
|
||||||
})
|
|
||||||
const skipped = createMemo(() => {
|
|
||||||
if (scrollReady()) return 0
|
|
||||||
return count() - rendered().length
|
|
||||||
})
|
|
||||||
const open = createMemo(() => props.open !== false)
|
|
||||||
const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reduce())
|
|
||||||
const noFade = () => props.noFadeOnCollapse === true
|
|
||||||
const overflowing = createMemo(() => count() > rows())
|
|
||||||
const shown = createMemo(() => Math.min(rows(), count()))
|
|
||||||
const step = createMemo(() => rowHeight() + rowGap())
|
|
||||||
const offset = createMemo(() => Math.max(0, count() - shown()) * step())
|
|
||||||
const body = createMemo(() => {
|
|
||||||
if (shown() > 0) {
|
|
||||||
return shown() * rowHeight() + Math.max(0, shown() - 1) * rowGap()
|
|
||||||
}
|
|
||||||
if (props.empty === undefined) return 0
|
|
||||||
return rowHeight()
|
|
||||||
})
|
|
||||||
const gap = createMemo(() => {
|
|
||||||
if (!fixed()) return 0
|
|
||||||
if (body() <= 0) return 0
|
|
||||||
return rowGap()
|
|
||||||
})
|
|
||||||
const height = createMemo(() => {
|
|
||||||
if (!open()) return 0
|
|
||||||
if (!fixed()) return body()
|
|
||||||
return fixedHeight() + gap() + body()
|
|
||||||
})
|
|
||||||
|
|
||||||
const key = (item: T, index: number) => {
|
|
||||||
const value = props.getKey
|
|
||||||
if (value) return value(item, index)
|
|
||||||
return String(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setTrack = (value: number) => {
|
|
||||||
if (!track) return
|
|
||||||
track.style.transform = `translateY(${-Math.round(value)}px)`
|
|
||||||
}
|
|
||||||
|
|
||||||
const setView = (value: number) => {
|
|
||||||
if (!view) return
|
|
||||||
view.style.height = `${Math.max(0, Math.round(value))}px`
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
setTrack(offset())
|
|
||||||
})
|
|
||||||
|
|
||||||
// Original WAAPI offset animation — untouched rolling behavior.
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
offset,
|
|
||||||
(next) => {
|
|
||||||
if (!track) return
|
|
||||||
if (scrollReady()) return
|
|
||||||
if (props.scrollable) return
|
|
||||||
if (!active()) {
|
|
||||||
shift?.stop()
|
|
||||||
shift = undefined
|
|
||||||
setTrack(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
shift?.stop()
|
|
||||||
const anim = animate(track, { transform: `translateY(${-next}px)` }, props.spring ?? GROW_SPRING)
|
|
||||||
shift = anim
|
|
||||||
anim.finished
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
if (shift !== anim) return
|
|
||||||
setTrack(next)
|
|
||||||
shift = undefined
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Scrollable transition: wait for the offset animation to finish,
|
|
||||||
// then batch all DOM changes in one synchronous pass.
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => props.scrollable === true,
|
|
||||||
(isScrollable) => {
|
|
||||||
if (!isScrollable) {
|
|
||||||
setScrollReady(false)
|
|
||||||
if (windowEl) {
|
|
||||||
windowEl.style.overflowY = ""
|
|
||||||
windowEl.style.maskImage = ""
|
|
||||||
windowEl.style.webkitMaskImage = ""
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Wait for the current offset animation to settle (if any).
|
|
||||||
const done = shift?.finished ?? Promise.resolve()
|
|
||||||
done
|
|
||||||
.catch(() => {})
|
|
||||||
.then(() => {
|
|
||||||
if (props.scrollable !== true) return
|
|
||||||
|
|
||||||
// Batch the signal update — Solid updates the DOM synchronously:
|
|
||||||
// rendered() returns all items, skipped() returns 0, padding-top removed,
|
|
||||||
// data-scrollable becomes "true".
|
|
||||||
batch(() => setScrollReady(true))
|
|
||||||
|
|
||||||
// Now the DOM has all items. Safe to switch layout strategy.
|
|
||||||
// CSS handles `transform: none !important` on [data-scrollable="true"].
|
|
||||||
if (windowEl) {
|
|
||||||
windowEl.style.overflowY = "auto"
|
|
||||||
windowEl.scrollTop = windowEl.scrollHeight
|
|
||||||
}
|
|
||||||
updateScrollMask()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when new items arrive in scrollable mode
|
|
||||||
const [userScrolled, setUserScrolled] = createSignal(false)
|
|
||||||
|
|
||||||
const updateScrollMask = () => {
|
|
||||||
if (!windowEl) return
|
|
||||||
if (!scrollReady()) {
|
|
||||||
windowEl.style.maskImage = ""
|
|
||||||
windowEl.style.webkitMaskImage = ""
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = windowEl
|
|
||||||
const atBottom = scrollHeight - scrollTop - clientHeight < 8
|
|
||||||
// Top fade is always present in scrollable mode (matches rolling mode appearance).
|
|
||||||
// Bottom fade only when not scrolled to the end.
|
|
||||||
const mask = atBottom
|
|
||||||
? "linear-gradient(to bottom, transparent 0, black 8px)"
|
|
||||||
: "linear-gradient(to bottom, transparent 0, black 8px, black calc(100% - 8px), transparent 100%)"
|
|
||||||
windowEl.style.maskImage = mask
|
|
||||||
windowEl.style.webkitMaskImage = mask
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!scrollReady()) {
|
|
||||||
setUserScrolled(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const _n = count()
|
|
||||||
const scrolled = userScrolled()
|
|
||||||
if (scrolled) return
|
|
||||||
if (windowEl) {
|
|
||||||
windowEl.scrollTop = windowEl.scrollHeight
|
|
||||||
updateScrollMask()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onWindowScroll = () => {
|
|
||||||
if (!windowEl || !scrollReady()) return
|
|
||||||
const atBottom = windowEl.scrollHeight - windowEl.scrollTop - windowEl.clientHeight < 8
|
|
||||||
setUserScrolled(!atBottom)
|
|
||||||
updateScrollMask()
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDGE_MASK = "linear-gradient(to top, transparent 0%, black 8px)"
|
|
||||||
const applyEdge = () => {
|
|
||||||
if (!view) return
|
|
||||||
edgeFade?.stop()
|
|
||||||
edgeFade = undefined
|
|
||||||
view.style.maskImage = EDGE_MASK
|
|
||||||
view.style.webkitMaskImage = EDGE_MASK
|
|
||||||
view.style.maskSize = "100% 100%"
|
|
||||||
view.style.maskRepeat = "no-repeat"
|
|
||||||
}
|
|
||||||
const clearEdge = () => {
|
|
||||||
if (!view) return
|
|
||||||
if (!active()) {
|
|
||||||
clearMaskStyles(view)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
edgeFade?.stop()
|
|
||||||
const anim = animate(view, { maskSize: "100% 200%" }, props.spring ?? GROW_SPRING)
|
|
||||||
edgeFade = anim
|
|
||||||
anim.finished
|
|
||||||
.catch(() => {})
|
|
||||||
.then(() => {
|
|
||||||
if (edgeFade !== anim || !view) return
|
|
||||||
clearMaskStyles(view)
|
|
||||||
edgeFade = undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(height, (next, prev) => {
|
|
||||||
if (!view) return
|
|
||||||
if (!active()) {
|
|
||||||
resize?.stop()
|
|
||||||
resize = undefined
|
|
||||||
setView(next)
|
|
||||||
view.style.opacity = ""
|
|
||||||
clearEdge()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const collapsing = next === 0 && prev !== undefined && prev > 0
|
|
||||||
const expanding = prev === 0 && next > 0
|
|
||||||
resize?.stop()
|
|
||||||
view.style.opacity = ""
|
|
||||||
applyEdge()
|
|
||||||
const spring = props.spring ?? GROW_SPRING
|
|
||||||
const anim = collapsing
|
|
||||||
? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: 0 }, spring)
|
|
||||||
: expanding
|
|
||||||
? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: [0, 1] }, spring)
|
|
||||||
: animate(view, { height: `${next}px` }, spring)
|
|
||||||
resize = anim
|
|
||||||
anim.finished
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
view.style.opacity = ""
|
|
||||||
if (resize !== anim) return
|
|
||||||
setView(next)
|
|
||||||
resize = undefined
|
|
||||||
clearEdge()
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
shift?.stop()
|
|
||||||
resize?.stop()
|
|
||||||
edgeFade?.stop()
|
|
||||||
shift = undefined
|
|
||||||
resize = undefined
|
|
||||||
edgeFade = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-component="rolling-results"
|
|
||||||
class={props.class}
|
|
||||||
data-open={open() ? "true" : "false"}
|
|
||||||
data-overflowing={overflowing() ? "true" : "false"}
|
|
||||||
data-scrollable={scrollReady() ? "true" : "false"}
|
|
||||||
data-fixed={fixed() ? "true" : "false"}
|
|
||||||
style={{
|
|
||||||
"--rolling-results-row-height": `${rowHeight()}px`,
|
|
||||||
"--rolling-results-fixed-height": `${fixed() ? fixedHeight() : 0}px`,
|
|
||||||
"--rolling-results-fixed-gap": `${gap()}px`,
|
|
||||||
"--rolling-results-row-gap": `${rowGap()}px`,
|
|
||||||
"--rolling-results-fade": `${Math.round(rowHeight() * 0.6)}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div ref={view} data-slot="rolling-results-viewport" aria-live="polite">
|
|
||||||
<Show when={fixed()}>
|
|
||||||
<div data-slot="rolling-results-fixed">{props.fixed}</div>
|
|
||||||
</Show>
|
|
||||||
<div ref={windowEl} data-slot="rolling-results-window" onScroll={onWindowScroll}>
|
|
||||||
<div data-slot="rolling-results-body">
|
|
||||||
<Show when={list().length === 0 && props.empty !== undefined}>
|
|
||||||
<div data-slot="rolling-results-empty">{props.empty}</div>
|
|
||||||
</Show>
|
|
||||||
<div
|
|
||||||
ref={track}
|
|
||||||
data-slot="rolling-results-track"
|
|
||||||
style={{ "padding-top": scrollReady() ? undefined : `${skipped() * step()}px` }}
|
|
||||||
>
|
|
||||||
<For each={rendered()}>
|
|
||||||
{(item, index) => (
|
|
||||||
<div data-slot="rolling-results-row" data-key={key(item, index())}>
|
|
||||||
{props.render(item, index())}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,13 +9,6 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
display: block;
|
|
||||||
overflow-anchor: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-view__viewport[data-reverse="true"] {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll-view__viewport::-webkit-scrollbar {
|
.scroll-view__viewport::-webkit-scrollbar {
|
||||||
@@ -52,6 +45,18 @@
|
|||||||
background-color: var(--border-strong-base);
|
background-color: var(--border-strong-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .scroll-view__thumb::after,
|
||||||
|
[data-theme="dark"] .scroll-view__thumb::after {
|
||||||
|
background-color: var(--border-weak-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .scroll-view__thumb:hover::after,
|
||||||
|
[data-theme="dark"] .scroll-view__thumb:hover::after,
|
||||||
|
.dark .scroll-view__thumb[data-dragging="true"]::after,
|
||||||
|
[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after {
|
||||||
|
background-color: var(--border-strong-base);
|
||||||
|
}
|
||||||
|
|
||||||
.scroll-view__thumb[data-visible="true"] {
|
.scroll-view__thumb[data-visible="true"] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js"
|
import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
|
||||||
import { animate, type AnimationPlaybackControls } from "motion"
|
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
import { FAST_SPRING } from "./motion"
|
|
||||||
|
|
||||||
export interface ScrollViewProps extends ComponentProps<"div"> {
|
export interface ScrollViewProps extends ComponentProps<"div"> {
|
||||||
viewportRef?: (el: HTMLDivElement) => void
|
viewportRef?: (el: HTMLDivElement) => void
|
||||||
reverse?: boolean
|
orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScrollView(props: ScrollViewProps) {
|
export function ScrollView(props: ScrollViewProps) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
|
const merged = mergeProps({ orientation: "vertical" }, props)
|
||||||
const [local, events, rest] = splitProps(
|
const [local, events, rest] = splitProps(
|
||||||
props,
|
merged,
|
||||||
["class", "children", "viewportRef", "style", "reverse"],
|
["class", "children", "viewportRef", "orientation", "style"],
|
||||||
[
|
[
|
||||||
"onScroll",
|
"onScroll",
|
||||||
"onWheel",
|
"onWheel",
|
||||||
@@ -26,9 +25,9 @@ export function ScrollView(props: ScrollViewProps) {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let rootRef!: HTMLDivElement
|
||||||
let viewportRef!: HTMLDivElement
|
let viewportRef!: HTMLDivElement
|
||||||
let thumbRef!: HTMLDivElement
|
let thumbRef!: HTMLDivElement
|
||||||
let anim: AnimationPlaybackControls | undefined
|
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = createSignal(false)
|
const [isHovered, setIsHovered] = createSignal(false)
|
||||||
const [isDragging, setIsDragging] = createSignal(false)
|
const [isDragging, setIsDragging] = createSignal(false)
|
||||||
@@ -37,8 +36,6 @@ export function ScrollView(props: ScrollViewProps) {
|
|||||||
const [thumbTop, setThumbTop] = createSignal(0)
|
const [thumbTop, setThumbTop] = createSignal(0)
|
||||||
const [showThumb, setShowThumb] = createSignal(false)
|
const [showThumb, setShowThumb] = createSignal(false)
|
||||||
|
|
||||||
const reverse = () => local.reverse === true
|
|
||||||
|
|
||||||
const updateThumb = () => {
|
const updateThumb = () => {
|
||||||
if (!viewportRef) return
|
if (!viewportRef) return
|
||||||
const { scrollTop, scrollHeight, clientHeight } = viewportRef
|
const { scrollTop, scrollHeight, clientHeight } = viewportRef
|
||||||
@@ -60,13 +57,9 @@ export function ScrollView(props: ScrollViewProps) {
|
|||||||
const maxScrollTop = scrollHeight - clientHeight
|
const maxScrollTop = scrollHeight - clientHeight
|
||||||
const maxThumbTop = trackHeight - height
|
const maxThumbTop = trackHeight - height
|
||||||
|
|
||||||
const top = (() => {
|
const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
|
||||||
if (maxScrollTop <= 0) return 0
|
|
||||||
if (!reverse()) return (scrollTop / maxScrollTop) * maxThumbTop
|
|
||||||
return ((maxScrollTop + scrollTop) / maxScrollTop) * maxThumbTop
|
|
||||||
})()
|
|
||||||
|
|
||||||
// Ensure thumb stays within bounds
|
// Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
|
||||||
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
|
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
|
||||||
|
|
||||||
setThumbHeight(height)
|
setThumbHeight(height)
|
||||||
@@ -89,7 +82,6 @@ export function ScrollView(props: ScrollViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
stop()
|
|
||||||
observer.disconnect()
|
observer.disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -131,31 +123,6 @@ export function ScrollView(props: ScrollViewProps) {
|
|||||||
thumbRef.addEventListener("pointerup", onPointerUp)
|
thumbRef.addEventListener("pointerup", onPointerUp)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
if (!anim) return
|
|
||||||
anim.stop()
|
|
||||||
anim = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit = (top: number) => {
|
|
||||||
const max = viewportRef.scrollHeight - viewportRef.clientHeight
|
|
||||||
if (reverse()) return Math.max(-max, Math.min(0, top))
|
|
||||||
return Math.max(0, Math.min(max, top))
|
|
||||||
}
|
|
||||||
|
|
||||||
const glide = (top: number) => {
|
|
||||||
stop()
|
|
||||||
anim = animate(viewportRef.scrollTop, limit(top), {
|
|
||||||
...FAST_SPRING,
|
|
||||||
onUpdate: (v) => {
|
|
||||||
viewportRef.scrollTop = v
|
|
||||||
},
|
|
||||||
onComplete: () => {
|
|
||||||
anim = undefined
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keybinds implementation
|
// Keybinds implementation
|
||||||
// We ensure the viewport has a tabindex so it can receive focus
|
// We ensure the viewport has a tabindex so it can receive focus
|
||||||
// We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
|
// We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
|
||||||
@@ -180,11 +147,11 @@ export function ScrollView(props: ScrollViewProps) {
|
|||||||
break
|
break
|
||||||
case "Home":
|
case "Home":
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
glide(reverse() ? -(viewportRef.scrollHeight - viewportRef.clientHeight) : 0)
|
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
|
||||||
break
|
break
|
||||||
case "End":
|
case "End":
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
glide(reverse() ? 0 : viewportRef.scrollHeight - viewportRef.clientHeight)
|
viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
|
||||||
break
|
break
|
||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -199,6 +166,7 @@ export function ScrollView(props: ScrollViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={rootRef}
|
||||||
class={`scroll-view ${local.class || ""}`}
|
class={`scroll-view ${local.class || ""}`}
|
||||||
style={local.style}
|
style={local.style}
|
||||||
onPointerEnter={() => setIsHovered(true)}
|
onPointerEnter={() => setIsHovered(true)}
|
||||||
@@ -209,26 +177,16 @@ export function ScrollView(props: ScrollViewProps) {
|
|||||||
<div
|
<div
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
class="scroll-view__viewport"
|
class="scroll-view__viewport"
|
||||||
data-reverse={reverse() ? "true" : undefined}
|
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
updateThumb()
|
updateThumb()
|
||||||
if (typeof events.onScroll === "function") events.onScroll(e as any)
|
if (typeof events.onScroll === "function") events.onScroll(e as any)
|
||||||
}}
|
}}
|
||||||
onWheel={(e) => {
|
onWheel={events.onWheel as any}
|
||||||
if (e.deltaY) stop()
|
onTouchStart={events.onTouchStart as any}
|
||||||
if (typeof events.onWheel === "function") events.onWheel(e as any)
|
|
||||||
}}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
stop()
|
|
||||||
if (typeof events.onTouchStart === "function") events.onTouchStart(e as any)
|
|
||||||
}}
|
|
||||||
onTouchMove={events.onTouchMove as any}
|
onTouchMove={events.onTouchMove as any}
|
||||||
onTouchEnd={events.onTouchEnd as any}
|
onTouchEnd={events.onTouchEnd as any}
|
||||||
onTouchCancel={events.onTouchCancel as any}
|
onTouchCancel={events.onTouchCancel as any}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={events.onPointerDown as any}
|
||||||
stop()
|
|
||||||
if (typeof events.onPointerDown === "function") events.onPointerDown(e as any)
|
|
||||||
}}
|
|
||||||
onClick={events.onClick as any}
|
onClick={events.onClick as any}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="region"
|
role="region"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[data-component="session-turn"] {
|
[data-component="session-turn"] {
|
||||||
|
--sticky-header-height: calc(var(--session-title-height, 0px) + 24px);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 0px;
|
gap: 18px;
|
||||||
overflow-anchor: none;
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,127 +43,30 @@
|
|||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="session-turn-assistant-lane"] {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-self: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-turn-thinking"] {
|
[data-slot="session-turn-thinking"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--text-weak);
|
color: var(--text-weak);
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
line-height: var(--line-height-large);
|
line-height: 20px;
|
||||||
height: 36px;
|
min-height: 20px;
|
||||||
|
|
||||||
[data-component="spinner"] {
|
[data-component="spinner"] {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
> [data-component="text-shimmer"] {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-turn-handoff-wrap"] {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-turn-handoff"] {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 37px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-turn-thinking"] {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
will-change: opacity, filter;
|
|
||||||
transition:
|
|
||||||
opacity 180ms ease-out,
|
|
||||||
filter 180ms ease-out,
|
|
||||||
transform 180ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-turn-thinking"][data-visible="false"] {
|
|
||||||
opacity: 0;
|
|
||||||
filter: blur(2px);
|
|
||||||
transform: translateY(1px);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-turn-thinking"][data-visible="true"] {
|
|
||||||
opacity: 1;
|
|
||||||
filter: blur(0px);
|
|
||||||
transform: translateY(0px);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-turn-meta"] {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
min-height: 37px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-turn-meta"][data-interrupted] {
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-turn-meta"] [data-component="tooltip-trigger"] {
|
|
||||||
display: inline-flex;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-turn-message-container"]:hover [data-slot="session-turn-meta"][data-visible="true"],
|
|
||||||
[data-slot="session-turn-message-container"]:focus-within [data-slot="session-turn-meta"][data-visible="true"] {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-turn-meta-label"] {
|
|
||||||
user-select: none;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: clip;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="text-reveal"].session-turn-thinking-heading {
|
[data-component="text-reveal"].session-turn-thinking-heading {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: clip;
|
|
||||||
white-space: nowrap;
|
|
||||||
line-height: inherit;
|
|
||||||
color: var(--text-weaker);
|
color: var(--text-weaker);
|
||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
|
|
||||||
[data-slot="text-reveal-track"],
|
|
||||||
[data-slot="text-reveal-entering"],
|
|
||||||
[data-slot="text-reveal-leaving"] {
|
|
||||||
min-height: 0;
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-card {
|
.error-card {
|
||||||
@@ -180,7 +84,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
gap: 0px;
|
gap: 12px;
|
||||||
|
|
||||||
> :first-child > [data-component="markdown"]:first-child {
|
> :first-child > [data-component="markdown"]:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -205,7 +109,6 @@
|
|||||||
|
|
||||||
[data-component="session-turn-diffs-trigger"] {
|
[data-component="session-turn-diffs-trigger"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 36px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -215,7 +118,7 @@
|
|||||||
|
|
||||||
[data-slot="session-turn-diffs-title"] {
|
[data-slot="session-turn-diffs-title"] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +136,7 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-x-large);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="session-turn-diffs-meta"] {
|
[data-slot="session-turn-diffs-meta"] {
|
||||||
@@ -269,10 +172,8 @@
|
|||||||
|
|
||||||
[data-slot="session-turn-diff-path"] {
|
[data-slot="session-turn-diff-path"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: baseline;
|
|
||||||
overflow: clip;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
@@ -280,22 +181,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="session-turn-diff-directory"] {
|
[data-slot="session-turn-diff-directory"] {
|
||||||
flex: 1 1 auto;
|
color: var(--text-base);
|
||||||
color: var(--text-weak);
|
overflow: hidden;
|
||||||
min-width: 0;
|
text-overflow: ellipsis;
|
||||||
overflow: clip;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
unicode-bidi: plaintext;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="session-turn-diff-filename"] {
|
[data-slot="session-turn-diff-filename"] {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
max-width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: clip;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,23 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2"
|
|||||||
import { useData } from "../context"
|
import { useData } from "../context"
|
||||||
import { useFileComponent } from "../context/file"
|
import { useFileComponent } from "../context/file"
|
||||||
|
|
||||||
import { same } from "@opencode-ai/util/array"
|
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js"
|
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { GrowBox } from "./grow-box"
|
import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part"
|
||||||
import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part"
|
|
||||||
import { Card } from "./card"
|
import { Card } from "./card"
|
||||||
import { Accordion } from "./accordion"
|
import { Accordion } from "./accordion"
|
||||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||||
import { Collapsible } from "./collapsible"
|
import { Collapsible } from "./collapsible"
|
||||||
import { DiffChanges } from "./diff-changes"
|
import { DiffChanges } from "./diff-changes"
|
||||||
import { Icon } from "./icon"
|
import { Icon } from "./icon"
|
||||||
import { IconButton } from "./icon-button"
|
|
||||||
import { TextShimmer } from "./text-shimmer"
|
import { TextShimmer } from "./text-shimmer"
|
||||||
import { TextReveal } from "./text-reveal"
|
|
||||||
import { list } from "./text-utils"
|
|
||||||
import { SessionRetry } from "./session-retry"
|
import { SessionRetry } from "./session-retry"
|
||||||
import { Tooltip } from "./tooltip"
|
import { TextReveal } from "./text-reveal"
|
||||||
import { createAutoScroll } from "../hooks"
|
import { createAutoScroll } from "../hooks"
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
|
|
||||||
function record(value: unknown): value is Record<string, unknown> {
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
@@ -77,12 +73,18 @@ function unwrap(message: string) {
|
|||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function same<T>(a: readonly T[], b: readonly T[]) {
|
||||||
|
if (a === b) return true
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
return a.every((x, i) => x === b[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
function list<T>(value: T[] | undefined | null, fallback: T[]) {
|
||||||
|
if (Array.isArray(value)) return value
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
const hidden = new Set(["todowrite", "todoread"])
|
const hidden = new Set(["todowrite", "todoread"])
|
||||||
const emptyMessages: MessageType[] = []
|
|
||||||
const emptyAssistant: AssistantMessage[] = []
|
|
||||||
const emptyDiffs: FileDiff[] = []
|
|
||||||
const idle: SessionStatus = { type: "idle" as const }
|
|
||||||
const handoffHoldMs = 120
|
|
||||||
|
|
||||||
function partState(part: PartType, showReasoningSummaries: boolean) {
|
function partState(part: PartType, showReasoningSummaries: boolean) {
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
@@ -139,7 +141,6 @@ export function SessionTurn(
|
|||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
sessionID: string
|
sessionID: string
|
||||||
messageID: string
|
messageID: string
|
||||||
animate?: boolean
|
|
||||||
showReasoningSummaries?: boolean
|
showReasoningSummaries?: boolean
|
||||||
shellToolDefaultOpen?: boolean
|
shellToolDefaultOpen?: boolean
|
||||||
editToolDefaultOpen?: boolean
|
editToolDefaultOpen?: boolean
|
||||||
@@ -158,7 +159,11 @@ export function SessionTurn(
|
|||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const fileComponent = useFileComponent()
|
const fileComponent = useFileComponent()
|
||||||
|
|
||||||
|
const emptyMessages: MessageType[] = []
|
||||||
const emptyParts: PartType[] = []
|
const emptyParts: PartType[] = []
|
||||||
|
const emptyAssistant: AssistantMessage[] = []
|
||||||
|
const emptyDiffs: FileDiff[] = []
|
||||||
|
const idle = { type: "idle" as const }
|
||||||
|
|
||||||
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
|
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
|
||||||
|
|
||||||
@@ -186,8 +191,42 @@ export function SessionTurn(
|
|||||||
return msg
|
return msg
|
||||||
})
|
})
|
||||||
|
|
||||||
const active = createMemo(() => props.active ?? false)
|
const pending = createMemo(() => {
|
||||||
const queued = createMemo(() => props.queued ?? false)
|
if (typeof props.active === "boolean" && typeof props.queued === "boolean") return
|
||||||
|
const messages = allMessages() ?? emptyMessages
|
||||||
|
return messages.findLast(
|
||||||
|
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingUser = createMemo(() => {
|
||||||
|
const item = pending()
|
||||||
|
if (!item?.parentID) return
|
||||||
|
const messages = allMessages() ?? emptyMessages
|
||||||
|
const result = Binary.search(messages, item.parentID, (m) => m.id)
|
||||||
|
const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID)
|
||||||
|
if (!msg || msg.role !== "user") return
|
||||||
|
return msg
|
||||||
|
})
|
||||||
|
|
||||||
|
const active = createMemo(() => {
|
||||||
|
if (typeof props.active === "boolean") return props.active
|
||||||
|
const msg = message()
|
||||||
|
const parent = pendingUser()
|
||||||
|
if (!msg || !parent) return false
|
||||||
|
return parent.id === msg.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const queued = createMemo(() => {
|
||||||
|
if (typeof props.queued === "boolean") return props.queued
|
||||||
|
const id = message()?.id
|
||||||
|
if (!id) return false
|
||||||
|
if (!pendingUser()) return false
|
||||||
|
const item = pending()
|
||||||
|
if (!item) return false
|
||||||
|
return id > item.id
|
||||||
|
})
|
||||||
|
|
||||||
const parts = createMemo(() => {
|
const parts = createMemo(() => {
|
||||||
const msg = message()
|
const msg = message()
|
||||||
if (!msg) return emptyParts
|
if (!msg) return emptyParts
|
||||||
@@ -250,7 +289,7 @@ export function SessionTurn(
|
|||||||
const error = createMemo(
|
const error = createMemo(
|
||||||
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
|
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
|
||||||
)
|
)
|
||||||
const assistantCopyPart = createMemo(() => {
|
const showAssistantCopyPartID = createMemo(() => {
|
||||||
const messages = assistantMessages()
|
const messages = assistantMessages()
|
||||||
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
@@ -260,18 +299,13 @@ export function SessionTurn(
|
|||||||
const parts = list(data.store.part?.[message.id], emptyParts)
|
const parts = list(data.store.part?.[message.id], emptyParts)
|
||||||
for (let j = parts.length - 1; j >= 0; j--) {
|
for (let j = parts.length - 1; j >= 0; j--) {
|
||||||
const part = parts[j]
|
const part = parts[j]
|
||||||
if (!part || part.type !== "text") continue
|
if (!part || part.type !== "text" || !part.text?.trim()) continue
|
||||||
const text = part.text?.trim()
|
return part.id
|
||||||
if (!text) continue
|
|
||||||
return {
|
|
||||||
id: part.id,
|
|
||||||
text,
|
|
||||||
message,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
})
|
})
|
||||||
const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null)
|
|
||||||
const errorText = createMemo(() => {
|
const errorText = createMemo(() => {
|
||||||
const msg = error()?.data?.message
|
const msg = error()?.data?.message
|
||||||
if (typeof msg === "string") return unwrap(msg)
|
if (typeof msg === "string") return unwrap(msg)
|
||||||
@@ -279,14 +313,18 @@ export function SessionTurn(
|
|||||||
return unwrap(String(msg))
|
return unwrap(String(msg))
|
||||||
})
|
})
|
||||||
|
|
||||||
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
|
const status = createMemo(() => {
|
||||||
const working = createMemo(() => {
|
if (props.status !== undefined) return props.status
|
||||||
if (status().type === "idle") return false
|
if (typeof props.active === "boolean" && !props.active) return idle
|
||||||
if (!message()) return false
|
return data.store.session_status[props.sessionID] ?? idle
|
||||||
return active()
|
|
||||||
})
|
})
|
||||||
|
const working = createMemo(() => status().type !== "idle" && active())
|
||||||
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
|
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
|
||||||
const showDiffSummary = createMemo(() => edited() > 0 && !working())
|
|
||||||
|
const assistantCopyPartID = createMemo(() => {
|
||||||
|
if (working()) return null
|
||||||
|
return showAssistantCopyPartID() ?? null
|
||||||
|
})
|
||||||
const turnDurationMs = createMemo(() => {
|
const turnDurationMs = createMemo(() => {
|
||||||
const start = message()?.time.created
|
const start = message()?.time.created
|
||||||
if (typeof start !== "number") return undefined
|
if (typeof start !== "number") return undefined
|
||||||
@@ -326,109 +364,13 @@ export function SessionTurn(
|
|||||||
.filter((text): text is string => !!text)
|
.filter((text): text is string => !!text)
|
||||||
.at(-1),
|
.at(-1),
|
||||||
)
|
)
|
||||||
const thinking = createMemo(() => {
|
const showThinking = createMemo(() => {
|
||||||
if (!working() || !!error()) return false
|
if (!working() || !!error()) return false
|
||||||
if (queued()) return false
|
if (queued()) return false
|
||||||
if (status().type === "retry") return false
|
if (status().type === "retry") return false
|
||||||
if (showReasoningSummaries()) return assistantVisible() === 0
|
if (showReasoningSummaries()) return assistantVisible() === 0
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
const hasAssistant = createMemo(() => assistantMessages().length > 0)
|
|
||||||
const animateEnabled = createMemo(() => props.animate !== false)
|
|
||||||
const [live, setLive] = createSignal(false)
|
|
||||||
const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled()))
|
|
||||||
const metaOpen = createMemo(() => !working() && !!assistantCopyPart())
|
|
||||||
const duration = createMemo(() => {
|
|
||||||
const ms = turnDurationMs()
|
|
||||||
if (typeof ms !== "number" || ms < 0) return ""
|
|
||||||
|
|
||||||
const total = Math.round(ms / 1000)
|
|
||||||
if (total < 60) return `${total}s`
|
|
||||||
|
|
||||||
const minutes = Math.floor(total / 60)
|
|
||||||
const seconds = total % 60
|
|
||||||
return `${minutes}m ${seconds}s`
|
|
||||||
})
|
|
||||||
const meta = createMemo(() => {
|
|
||||||
const item = assistantCopyPart()
|
|
||||||
if (!item) return ""
|
|
||||||
|
|
||||||
const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : ""
|
|
||||||
const model = item.message.modelID
|
|
||||||
? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[
|
|
||||||
item.message.modelID
|
|
||||||
]?.name ?? item.message.modelID)
|
|
||||||
: ""
|
|
||||||
return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0")
|
|
||||||
})
|
|
||||||
const [copied, setCopied] = createSignal(false)
|
|
||||||
const [handoffHold, setHandoffHold] = createSignal(false)
|
|
||||||
const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold())
|
|
||||||
const handoffOpen = createMemo(() => thinkingVisible() || metaOpen())
|
|
||||||
const lane = createMemo(() => hasAssistant() || handoffOpen())
|
|
||||||
|
|
||||||
let liveFrame: number | undefined
|
|
||||||
let copiedTimer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
let handoffTimer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
|
|
||||||
const copyAssistant = async () => {
|
|
||||||
const text = assistantCopyPart()?.text
|
|
||||||
if (!text) return
|
|
||||||
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
setCopied(true)
|
|
||||||
if (copiedTimer !== undefined) clearTimeout(copiedTimer)
|
|
||||||
copiedTimer = setTimeout(() => {
|
|
||||||
copiedTimer = undefined
|
|
||||||
setCopied(false)
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => [animateEnabled(), working()] as const,
|
|
||||||
([enabled, isWorking]) => {
|
|
||||||
if (liveFrame !== undefined) {
|
|
||||||
cancelAnimationFrame(liveFrame)
|
|
||||||
liveFrame = undefined
|
|
||||||
}
|
|
||||||
if (!enabled || !isWorking || live()) return
|
|
||||||
liveFrame = requestAnimationFrame(() => {
|
|
||||||
liveFrame = undefined
|
|
||||||
setLive(true)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => [thinkingOpen(), metaOpen()] as const,
|
|
||||||
([thinkingNow, metaNow]) => {
|
|
||||||
if (handoffTimer !== undefined) {
|
|
||||||
clearTimeout(handoffTimer)
|
|
||||||
handoffTimer = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thinkingNow) {
|
|
||||||
setHandoffHold(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metaNow) {
|
|
||||||
setHandoffHold(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!handoffHold()) return
|
|
||||||
handoffTimer = setTimeout(() => {
|
|
||||||
handoffTimer = undefined
|
|
||||||
setHandoffHold(false)
|
|
||||||
}, handoffHoldMs)
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const autoScroll = createAutoScroll({
|
const autoScroll = createAutoScroll({
|
||||||
working,
|
working,
|
||||||
@@ -436,119 +378,6 @@ export function SessionTurn(
|
|||||||
overflowAnchor: "dynamic",
|
overflowAnchor: "dynamic",
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (liveFrame !== undefined) cancelAnimationFrame(liveFrame)
|
|
||||||
if (copiedTimer !== undefined) clearTimeout(copiedTimer)
|
|
||||||
if (handoffTimer !== undefined) clearTimeout(handoffTimer)
|
|
||||||
})
|
|
||||||
|
|
||||||
const turnDiffSummary = () => (
|
|
||||||
<div data-slot="session-turn-diffs">
|
|
||||||
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
|
|
||||||
<Collapsible.Trigger>
|
|
||||||
<div data-component="session-turn-diffs-trigger">
|
|
||||||
<div data-slot="session-turn-diffs-title">
|
|
||||||
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
|
|
||||||
<span data-slot="session-turn-diffs-count">
|
|
||||||
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
|
|
||||||
</span>
|
|
||||||
<div data-slot="session-turn-diffs-meta">
|
|
||||||
<DiffChanges changes={diffs()} variant="bars" />
|
|
||||||
<Collapsible.Arrow />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Collapsible.Trigger>
|
|
||||||
<Collapsible.Content>
|
|
||||||
<Show when={open()}>
|
|
||||||
<div data-component="session-turn-diffs-content">
|
|
||||||
<Accordion
|
|
||||||
multiple
|
|
||||||
style={{ "--sticky-accordion-offset": "37px" }}
|
|
||||||
value={expanded()}
|
|
||||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
|
||||||
>
|
|
||||||
<For each={diffs()}>
|
|
||||||
{(diff) => {
|
|
||||||
const active = createMemo(() => expanded().includes(diff.file))
|
|
||||||
const [visible, setVisible] = createSignal(false)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
active,
|
|
||||||
(value) => {
|
|
||||||
if (!value) {
|
|
||||||
setVisible(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!active()) return
|
|
||||||
setVisible(true)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Accordion.Item value={diff.file}>
|
|
||||||
<StickyAccordionHeader>
|
|
||||||
<Accordion.Trigger>
|
|
||||||
<div data-slot="session-turn-diff-trigger">
|
|
||||||
<span data-slot="session-turn-diff-path">
|
|
||||||
<Show when={diff.file.includes("/")}>
|
|
||||||
<span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
|
||||||
</Show>
|
|
||||||
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
|
|
||||||
</span>
|
|
||||||
<div data-slot="session-turn-diff-meta">
|
|
||||||
<span data-slot="session-turn-diff-changes">
|
|
||||||
<DiffChanges changes={diff} />
|
|
||||||
</span>
|
|
||||||
<span data-slot="session-turn-diff-chevron">
|
|
||||||
<Icon name="chevron-down" size="small" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion.Trigger>
|
|
||||||
</StickyAccordionHeader>
|
|
||||||
<Accordion.Content>
|
|
||||||
<Show when={visible()}>
|
|
||||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
|
||||||
<Dynamic
|
|
||||||
component={fileComponent}
|
|
||||||
mode="diff"
|
|
||||||
before={{ name: diff.file, contents: diff.before }}
|
|
||||||
after={{ name: diff.file, contents: diff.after }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</Collapsible.Content>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const divider = (label: string) => (
|
|
||||||
<div data-component="compaction-part">
|
|
||||||
<div data-slot="compaction-part-divider">
|
|
||||||
<span data-slot="compaction-part-line" />
|
|
||||||
<span data-slot="compaction-part-label" class="text-12-regular text-text-weak">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span data-slot="compaction-part-line" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-component="session-turn" class={props.classes?.root}>
|
<div data-component="session-turn" class={props.classes?.root}>
|
||||||
<div
|
<div
|
||||||
@@ -559,120 +388,149 @@ export function SessionTurn(
|
|||||||
>
|
>
|
||||||
<div onClick={autoScroll.handleInteraction}>
|
<div onClick={autoScroll.handleInteraction}>
|
||||||
<Show when={message()}>
|
<Show when={message()}>
|
||||||
{(msg) => (
|
<div
|
||||||
<div
|
ref={autoScroll.contentRef}
|
||||||
ref={autoScroll.contentRef}
|
data-message={message()!.id}
|
||||||
data-message={msg().id}
|
data-slot="session-turn-message-container"
|
||||||
data-slot="session-turn-message-container"
|
class={props.classes?.container}
|
||||||
class={props.classes?.container}
|
>
|
||||||
>
|
<div data-slot="session-turn-message-content" aria-live="off">
|
||||||
<div data-slot="session-turn-message-content" aria-live="off">
|
<Message message={message()!} parts={parts()} interrupted={interrupted()} queued={queued()} />
|
||||||
<UserMessageDisplay
|
</div>
|
||||||
message={msg()}
|
<Show when={compaction()}>
|
||||||
parts={parts()}
|
<div data-slot="session-turn-compaction">
|
||||||
interrupted={interrupted()}
|
<Part part={compaction()!} message={message()!} hideDetails />
|
||||||
animate={props.animate}
|
</div>
|
||||||
queued={queued()}
|
</Show>
|
||||||
|
<Show when={assistantMessages().length > 0}>
|
||||||
|
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
|
||||||
|
<AssistantParts
|
||||||
|
messages={assistantMessages()}
|
||||||
|
showAssistantCopyPartID={assistantCopyPartID()}
|
||||||
|
turnDurationMs={turnDurationMs()}
|
||||||
|
working={working()}
|
||||||
|
showReasoningSummaries={showReasoningSummaries()}
|
||||||
|
shellToolDefaultOpen={props.shellToolDefaultOpen}
|
||||||
|
editToolDefaultOpen={props.editToolDefaultOpen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Show when={compaction()}>
|
</Show>
|
||||||
{(part) => (
|
<Show when={showThinking()}>
|
||||||
<GrowBox animate={props.animate !== false} fade gap={8} class="w-full min-w-0">
|
<div data-slot="session-turn-thinking">
|
||||||
<div data-slot="session-turn-compaction">
|
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
||||||
<Part part={part()} message={msg()} hideDetails />
|
<Show when={!showReasoningSummaries()}>
|
||||||
</div>
|
<TextReveal
|
||||||
</GrowBox>
|
text={reasoningHeading()}
|
||||||
)}
|
class="session-turn-thinking-heading"
|
||||||
</Show>
|
travel={25}
|
||||||
<div data-slot="session-turn-assistant-lane" aria-hidden={!lane()}>
|
duration={700}
|
||||||
<Show when={hasAssistant()}>
|
/>
|
||||||
<div
|
|
||||||
data-slot="session-turn-assistant-content"
|
|
||||||
aria-hidden={working()}
|
|
||||||
style={{ contain: "layout paint" }}
|
|
||||||
>
|
|
||||||
<AssistantParts
|
|
||||||
messages={assistantMessages()}
|
|
||||||
showAssistantCopyPartID={assistantCopyPartID()}
|
|
||||||
showTurnDiffSummary={showDiffSummary()}
|
|
||||||
turnDiffSummary={turnDiffSummary}
|
|
||||||
working={working()}
|
|
||||||
animate={live()}
|
|
||||||
showReasoningSummaries={showReasoningSummaries()}
|
|
||||||
shellToolDefaultOpen={props.shellToolDefaultOpen}
|
|
||||||
editToolDefaultOpen={props.editToolDefaultOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
<GrowBox
|
</div>
|
||||||
animate={live()}
|
</Show>
|
||||||
animateToggle={live()}
|
<SessionRetry status={status()} show={active()} />
|
||||||
open={handoffOpen()}
|
<Show when={edited() > 0 && !working()}>
|
||||||
fade
|
<div data-slot="session-turn-diffs">
|
||||||
slot="session-turn-handoff-wrap"
|
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
|
||||||
>
|
<Collapsible.Trigger>
|
||||||
<div data-slot="session-turn-handoff">
|
<div data-component="session-turn-diffs-trigger">
|
||||||
<div data-slot="session-turn-thinking" data-visible={thinkingVisible() ? "true" : "false"}>
|
<div data-slot="session-turn-diffs-title">
|
||||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
|
||||||
<TextReveal
|
<span data-slot="session-turn-diffs-count">
|
||||||
text={!showReasoningSummaries() ? (reasoningHeading() ?? "") : ""}
|
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
|
||||||
class="session-turn-thinking-heading"
|
</span>
|
||||||
travel={25}
|
<div data-slot="session-turn-diffs-meta">
|
||||||
duration={900}
|
<DiffChanges changes={diffs()} variant="bars" />
|
||||||
/>
|
<Collapsible.Arrow />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={metaOpen()}>
|
</Collapsible.Trigger>
|
||||||
<div
|
<Collapsible.Content>
|
||||||
data-slot="session-turn-meta"
|
<Show when={open()}>
|
||||||
data-visible={thinkingVisible() ? "false" : "true"}
|
<div data-component="session-turn-diffs-content">
|
||||||
data-interrupted={interrupted() ? "" : undefined}
|
<Accordion
|
||||||
>
|
multiple
|
||||||
<Tooltip
|
style={{ "--sticky-accordion-offset": "40px" }}
|
||||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
|
value={expanded()}
|
||||||
placement="top"
|
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||||
gutter={4}
|
|
||||||
>
|
>
|
||||||
<IconButton
|
<For each={diffs()}>
|
||||||
icon={copied() ? "check" : "copy"}
|
{(diff) => {
|
||||||
size="normal"
|
const active = createMemo(() => expanded().includes(diff.file))
|
||||||
variant="ghost"
|
const [visible, setVisible] = createSignal(false)
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
onClick={() => void copyAssistant()}
|
createEffect(
|
||||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
|
on(
|
||||||
/>
|
active,
|
||||||
</Tooltip>
|
(value) => {
|
||||||
<Show when={meta()}>
|
if (!value) {
|
||||||
<span
|
setVisible(false)
|
||||||
data-slot="session-turn-meta-label"
|
return
|
||||||
class="text-12-regular text-text-weak cursor-default"
|
}
|
||||||
>
|
|
||||||
{meta()}
|
requestAnimationFrame(() => {
|
||||||
</span>
|
if (!active()) return
|
||||||
</Show>
|
setVisible(true)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion.Item value={diff.file}>
|
||||||
|
<StickyAccordionHeader>
|
||||||
|
<Accordion.Trigger>
|
||||||
|
<div data-slot="session-turn-diff-trigger">
|
||||||
|
<span data-slot="session-turn-diff-path">
|
||||||
|
<Show when={diff.file.includes("/")}>
|
||||||
|
<span data-slot="session-turn-diff-directory">
|
||||||
|
{`\u202A${getDirectory(diff.file)}\u202C`}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
|
||||||
|
</span>
|
||||||
|
<div data-slot="session-turn-diff-meta">
|
||||||
|
<span data-slot="session-turn-diff-changes">
|
||||||
|
<DiffChanges changes={diff} />
|
||||||
|
</span>
|
||||||
|
<span data-slot="session-turn-diff-chevron">
|
||||||
|
<Icon name="chevron-down" size="small" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</StickyAccordionHeader>
|
||||||
|
<Accordion.Content>
|
||||||
|
<Show when={visible()}>
|
||||||
|
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||||
|
<Dynamic
|
||||||
|
component={fileComponent}
|
||||||
|
mode="diff"
|
||||||
|
before={{ name: diff.file, contents: diff.before }}
|
||||||
|
after={{ name: diff.file, contents: diff.after }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</Collapsible.Content>
|
||||||
</GrowBox>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
<GrowBox animate={props.animate !== false} fade gap={0} open={interrupted()} class="w-full min-w-0">
|
</Show>
|
||||||
{divider(i18n.t("ui.message.interrupted"))}
|
<Show when={error()}>
|
||||||
</GrowBox>
|
<Card variant="error" class="error-card">
|
||||||
<SessionRetry status={status()} show={active()} />
|
{errorText()}
|
||||||
<GrowBox
|
</Card>
|
||||||
animate={props.animate !== false}
|
</Show>
|
||||||
fade
|
</div>
|
||||||
gap={0}
|
|
||||||
open={showDiffSummary() && !assistantCopyPartID()}
|
|
||||||
>
|
|
||||||
{turnDiffSummary()}
|
|
||||||
</GrowBox>
|
|
||||||
<Show when={error()}>
|
|
||||||
<Card variant="error" class="error-card">
|
|
||||||
{errorText()}
|
|
||||||
</Card>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
|
||||||
import stripAnsi from "strip-ansi"
|
|
||||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
|
||||||
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
import { useI18n } from "../context/i18n"
|
|
||||||
import { RollingResults } from "./rolling-results"
|
|
||||||
import { Icon } from "./icon"
|
|
||||||
import { IconButton } from "./icon-button"
|
|
||||||
import { TextShimmer } from "./text-shimmer"
|
|
||||||
import { Tooltip } from "./tooltip"
|
|
||||||
import { GROW_SPRING } from "./motion"
|
|
||||||
import { useSpring } from "./motion-spring"
|
|
||||||
import { busy, createThrottledValue, updateScrollMask, useCollapsible, useRowWipe, useToolFade } from "./tool-utils"
|
|
||||||
|
|
||||||
function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
|
|
||||||
let ref: HTMLSpanElement | undefined
|
|
||||||
useToolFade(() => ref, { wipe: true, animate: props.animate })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span data-slot="shell-rolling-subtitle">
|
|
||||||
<span ref={ref}>{props.text}</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstLine(text: string) {
|
|
||||||
return text
|
|
||||||
.split(/\r\n|\n|\r/g)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.find((item) => item.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function shellRows(output: string) {
|
|
||||||
const rows: { id: string; text: string }[] = []
|
|
||||||
const lines = output
|
|
||||||
.split(/\r\n|\n|\r/g)
|
|
||||||
.map((item) => item.trimEnd())
|
|
||||||
.filter((item) => item.length > 0)
|
|
||||||
const start = Math.max(0, lines.length - 80)
|
|
||||||
for (let i = start; i < lines.length; i++) {
|
|
||||||
rows.push({ id: `line:${i}`, text: lines[i]! })
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShellRollingCommand(props: { text: string; animate?: boolean }) {
|
|
||||||
let ref: HTMLSpanElement | undefined
|
|
||||||
useToolFade(() => ref, { wipe: true, animate: props.animate })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-component="shell-rolling-command">
|
|
||||||
<span ref={ref} data-slot="shell-rolling-text">
|
|
||||||
<span data-slot="shell-rolling-prompt">$</span> {props.text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
|
|
||||||
const i18n = useI18n()
|
|
||||||
const rows = 10
|
|
||||||
const rowHeight = 22
|
|
||||||
const max = rows * rowHeight
|
|
||||||
|
|
||||||
let contentRef: HTMLDivElement | undefined
|
|
||||||
let bodyRef: HTMLDivElement | undefined
|
|
||||||
let scrollRef: HTMLDivElement | undefined
|
|
||||||
let topRef: HTMLDivElement | undefined
|
|
||||||
const [copied, setCopied] = createSignal(false)
|
|
||||||
const [cap, setCap] = createSignal(max)
|
|
||||||
|
|
||||||
const updateMask = () => {
|
|
||||||
if (scrollRef) updateScrollMask(scrollRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resize = () => {
|
|
||||||
const top = Math.ceil(topRef?.getBoundingClientRect().height ?? 0)
|
|
||||||
setCap(Math.max(rowHeight * 2, max - top - (props.out ? 1 : 0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const measure = () => {
|
|
||||||
resize()
|
|
||||||
return Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
resize()
|
|
||||||
if (!topRef) return
|
|
||||||
const obs = new ResizeObserver(resize)
|
|
||||||
obs.observe(topRef)
|
|
||||||
onCleanup(() => obs.disconnect())
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
props.cmd
|
|
||||||
props.out
|
|
||||||
queueMicrotask(() => {
|
|
||||||
resize()
|
|
||||||
updateMask()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
useCollapsible({
|
|
||||||
content: () => contentRef,
|
|
||||||
body: () => bodyRef,
|
|
||||||
open: () => props.open,
|
|
||||||
measure,
|
|
||||||
onOpen: updateMask,
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleCopy = async (e: MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
const cmd = props.cmd ? `$ ${props.cmd}` : ""
|
|
||||||
const text = `${cmd}${props.out ? `${cmd ? "\n\n" : ""}${props.out}` : ""}`
|
|
||||||
if (!text) return
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
|
|
||||||
<div ref={bodyRef} data-component="shell-expanded-shell">
|
|
||||||
<div data-slot="shell-expanded-body">
|
|
||||||
<div ref={topRef} data-slot="shell-expanded-top">
|
|
||||||
<div data-slot="shell-expanded-command">
|
|
||||||
<span data-slot="shell-expanded-prompt">$</span>
|
|
||||||
<span data-slot="shell-expanded-input">{props.cmd}</span>
|
|
||||||
</div>
|
|
||||||
<div data-slot="shell-expanded-actions">
|
|
||||||
<Tooltip
|
|
||||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
|
||||||
placement="top"
|
|
||||||
gutter={4}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
icon={copied() ? "check" : "copy"}
|
|
||||||
size="small"
|
|
||||||
variant="ghost"
|
|
||||||
class="shell-expanded-copy"
|
|
||||||
onMouseDown={(e: MouseEvent) => e.preventDefault()}
|
|
||||||
onClick={handleCopy}
|
|
||||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={props.out}>
|
|
||||||
<>
|
|
||||||
<div data-slot="shell-expanded-divider" />
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
data-component="shell-expanded-output"
|
|
||||||
data-scrollable
|
|
||||||
onScroll={updateMask}
|
|
||||||
style={{ "max-height": `${cap()}px` }}
|
|
||||||
>
|
|
||||||
<pre data-slot="shell-expanded-pre">
|
|
||||||
<code>{props.out}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShellRollingResults(props: { part: ToolPart; animate?: boolean; defaultOpen?: boolean }) {
|
|
||||||
const i18n = useI18n()
|
|
||||||
const reduce = useReducedMotion()
|
|
||||||
const wiped = new Set<string>()
|
|
||||||
const [mounted, setMounted] = createSignal(false)
|
|
||||||
const [open, setOpen] = createSignal(props.defaultOpen ?? true)
|
|
||||||
onMount(() => setMounted(true))
|
|
||||||
const state = createMemo(() => props.part.state as Record<string, any>)
|
|
||||||
const pending = createMemo(() => busy(props.part.state.status))
|
|
||||||
const expanded = createMemo(() => open() && !pending())
|
|
||||||
const previewOpen = createMemo(() => open() && pending())
|
|
||||||
const command = createMemo(() => {
|
|
||||||
const value = state().input?.command ?? state().metadata?.command
|
|
||||||
if (typeof value === "string") return value
|
|
||||||
return ""
|
|
||||||
})
|
|
||||||
const subtitle = createMemo(() => {
|
|
||||||
const value = state().input?.description ?? state().metadata?.description
|
|
||||||
if (typeof value === "string" && value.trim().length > 0) return value
|
|
||||||
return firstLine(command()) ?? ""
|
|
||||||
})
|
|
||||||
const output = createMemo(() => {
|
|
||||||
const value = state().output ?? state().metadata?.output
|
|
||||||
if (typeof value === "string") return value
|
|
||||||
return ""
|
|
||||||
})
|
|
||||||
const skip = () => reduce() || props.animate === false
|
|
||||||
const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
|
|
||||||
const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)
|
|
||||||
const previewOpacity = useSpring(() => (previewOpen() ? 1 : 0), GROW_SPRING)
|
|
||||||
const previewBlur = useSpring(() => (previewOpen() ? 0 : 2), GROW_SPRING)
|
|
||||||
const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING)
|
|
||||||
let headerClipRef: HTMLDivElement | undefined
|
|
||||||
const handleHeaderClick = () => {
|
|
||||||
const el = headerClipRef
|
|
||||||
const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null
|
|
||||||
const beforeY = el?.getBoundingClientRect().top ?? 0
|
|
||||||
setOpen((prev) => !prev)
|
|
||||||
if (viewport && el) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const afterY = el.getBoundingClientRect().top
|
|
||||||
const delta = afterY - beforeY
|
|
||||||
if (delta !== 0) viewport.scrollTop += delta
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const line = createMemo(() => firstLine(command()))
|
|
||||||
const fixed = createMemo(() => {
|
|
||||||
const value = line()
|
|
||||||
if (!value) return
|
|
||||||
return <ShellRollingCommand text={value} animate={props.animate} />
|
|
||||||
})
|
|
||||||
const text = createThrottledValue(() => stripAnsi(output()))
|
|
||||||
const rows = createMemo(() => shellRows(text()))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-component="shell-rolling-results"
|
|
||||||
style={{ opacity: skip() ? (mounted() ? 1 : 0) : opacity(), filter: `blur(${skip() ? 0 : blur()}px)` }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={headerClipRef}
|
|
||||||
data-slot="shell-rolling-header-clip"
|
|
||||||
data-scroll-preserve
|
|
||||||
data-clickable="true"
|
|
||||||
onClick={handleHeaderClick}
|
|
||||||
style={{ height: `${skip() ? (mounted() ? 37 : 0) : headerHeight()}px`, overflow: "clip" }}
|
|
||||||
>
|
|
||||||
<div data-slot="shell-rolling-header">
|
|
||||||
<span data-slot="shell-rolling-title">
|
|
||||||
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
|
|
||||||
</span>
|
|
||||||
<Show when={subtitle()}>{(text) => <ShellRollingSubtitle text={text()} animate={props.animate} />}</Show>
|
|
||||||
<span data-slot="shell-rolling-actions">
|
|
||||||
<span data-slot="shell-rolling-arrow" data-open={open() ? "true" : "false"}>
|
|
||||||
<Icon name="chevron-down" size="small" />
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
data-slot="shell-rolling-preview"
|
|
||||||
style={{
|
|
||||||
opacity: skip() ? (previewOpen() ? 1 : 0) : previewOpacity(),
|
|
||||||
filter: `blur(${skip() ? 0 : previewBlur()}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RollingResults
|
|
||||||
class="shell-rolling-output"
|
|
||||||
noFadeOnCollapse
|
|
||||||
items={rows()}
|
|
||||||
fixed={fixed()}
|
|
||||||
fixedHeight={22}
|
|
||||||
rows={5}
|
|
||||||
rowHeight={22}
|
|
||||||
rowGap={0}
|
|
||||||
open={previewOpen()}
|
|
||||||
animate={props.animate !== false}
|
|
||||||
getKey={(row) => row.id}
|
|
||||||
render={(row) => {
|
|
||||||
const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
|
|
||||||
useRowWipe({
|
|
||||||
id: () => row.id,
|
|
||||||
text: () => row.text,
|
|
||||||
ref: textRef,
|
|
||||||
seen: wiped,
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<div data-component="shell-rolling-row">
|
|
||||||
<span ref={setTextRef} data-slot="shell-rolling-text">
|
|
||||||
{row.text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ShellExpanded cmd={command()} out={text()} open={expanded()} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
[data-component="shell-submessage"] {
|
[data-component="shell-submessage"] {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
|
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
line-height: inherit;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
* Instead of sliding text through a fixed mask (odometer style),
|
* Instead of sliding text through a fixed mask (odometer style),
|
||||||
* the mask itself sweeps across each span to reveal/hide text.
|
* the mask itself sweeps across each span to reveal/hide text.
|
||||||
*
|
*
|
||||||
* Direction: bottom-to-top. New text rises in from below, old text exits upward.
|
* Direction: top-to-bottom. New text drops in from above, old text exits downward.
|
||||||
*
|
*
|
||||||
* Entering: gradient reveals bottom-to-top (bottom of text appears first).
|
* Entering: gradient reveals top-to-bottom (top of text appears first).
|
||||||
* gradient(to bottom, white 33%, transparent 33%+edge)
|
* gradient(to bottom, white 33%, transparent 33%+edge)
|
||||||
* pos 0 100% = transparent covers element = hidden
|
* pos 0 100% = transparent covers element = hidden
|
||||||
* pos 0 0% = white covers element = visible
|
* pos 0 0% = white covers element = visible
|
||||||
*
|
*
|
||||||
* Leaving: gradient hides bottom-to-top (bottom of text disappears first).
|
* Leaving: gradient hides top-to-bottom (top of text disappears first).
|
||||||
* gradient(to top, white 33%, transparent 33%+edge)
|
* gradient(to top, white 33%, transparent 33%+edge)
|
||||||
* pos 0 100% = white covers element = visible
|
* pos 0 100% = white covers element = visible
|
||||||
* pos 0 0% = transparent covers element = hidden
|
* pos 0 0% = transparent covers element = hidden
|
||||||
@@ -56,17 +56,17 @@
|
|||||||
transition-timing-function: var(--_spring);
|
transition-timing-function: var(--_spring);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── entering: reveal bottom-to-top ──
|
/* ── entering: reveal top-to-bottom ──
|
||||||
* Gradient(to bottom): white at top, transparent at bottom of mask.
|
* Gradient(to top): white at bottom, transparent at top of mask.
|
||||||
* Settled pos 0 0% = white covers element = visible
|
* Settled pos 0 100% = white covers element = visible
|
||||||
* Swap pos 0 100% = transparent covers = hidden
|
* Swap pos 0 0% = transparent covers = hidden
|
||||||
* Rises from below: translateY(travel) → translateY(0)
|
* Slides from above: translateY(-travel) → translateY(0)
|
||||||
*/
|
*/
|
||||||
[data-slot="text-reveal-entering"] {
|
[data-slot="text-reveal-entering"] {
|
||||||
mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
|
mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
|
||||||
-webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
|
-webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
|
||||||
mask-position: 0 0%;
|
mask-position: 0 100%;
|
||||||
-webkit-mask-position: 0 0%;
|
-webkit-mask-position: 0 100%;
|
||||||
transition-property:
|
transition-property:
|
||||||
mask-position,
|
mask-position,
|
||||||
-webkit-mask-position,
|
-webkit-mask-position,
|
||||||
@@ -74,37 +74,37 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── leaving: hide bottom-to-top + slide upward ──
|
/* ── leaving: hide top-to-bottom + slide downward ──
|
||||||
* Gradient(to top): white at bottom, transparent at top of mask.
|
* Gradient(to bottom): white at top, transparent at bottom of mask.
|
||||||
* Swap pos 0 100% = white covers element = visible
|
* Swap pos 0 0% = white covers element = visible
|
||||||
* Settled pos 0 0% = transparent covers = hidden
|
* Settled pos 0 100% = transparent covers = hidden
|
||||||
* Slides up: translateY(0) → translateY(-travel)
|
* Slides down: translateY(0) → translateY(travel)
|
||||||
*/
|
*/
|
||||||
[data-slot="text-reveal-leaving"] {
|
[data-slot="text-reveal-leaving"] {
|
||||||
mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
|
mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
|
||||||
-webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
|
-webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
|
||||||
mask-position: 0 0%;
|
mask-position: 0 100%;
|
||||||
-webkit-mask-position: 0 0%;
|
-webkit-mask-position: 0 100%;
|
||||||
transition-property:
|
transition-property:
|
||||||
mask-position,
|
mask-position,
|
||||||
-webkit-mask-position,
|
-webkit-mask-position,
|
||||||
transform;
|
transform;
|
||||||
transform: translateY(calc(var(--_travel) * -1));
|
transform: translateY(var(--_travel));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── swapping: instant reset ──
|
/* ── swapping: instant reset ──
|
||||||
* Snap entering to hidden (below), leaving to visible (center).
|
* Snap entering to hidden (above), leaving to visible (center).
|
||||||
*/
|
*/
|
||||||
&[data-swapping="true"] [data-slot="text-reveal-entering"] {
|
&[data-swapping="true"] [data-slot="text-reveal-entering"] {
|
||||||
mask-position: 0 100%;
|
mask-position: 0 0%;
|
||||||
-webkit-mask-position: 0 100%;
|
-webkit-mask-position: 0 0%;
|
||||||
transform: translateY(var(--_travel));
|
transform: translateY(calc(var(--_travel) * -1));
|
||||||
transition-duration: 0ms !important;
|
transition-duration: 0ms !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-swapping="true"] [data-slot="text-reveal-leaving"] {
|
&[data-swapping="true"] [data-slot="text-reveal-leaving"] {
|
||||||
mask-position: 0 100%;
|
mask-position: 0 0%;
|
||||||
-webkit-mask-position: 0 100%;
|
-webkit-mask-position: 0 0%;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
transition-duration: 0ms !important;
|
transition-duration: 0ms !important;
|
||||||
}
|
}
|
||||||
@@ -126,14 +126,15 @@
|
|||||||
&[data-truncate="true"] [data-slot="text-reveal-track"] {
|
&[data-truncate="true"] [data-slot="text-reveal-track"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-truncate="true"] [data-slot="text-reveal-entering"],
|
&[data-truncate="true"] [data-slot="text-reveal-entering"],
|
||||||
&[data-truncate="true"] [data-slot="text-reveal-leaving"] {
|
&[data-truncate="true"] [data-slot="text-reveal-leaving"] {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
|
import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
|
||||||
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
import {
|
|
||||||
animate,
|
|
||||||
type AnimationPlaybackControls,
|
|
||||||
clearFadeStyles,
|
|
||||||
clearMaskStyles,
|
|
||||||
GROW_SPRING,
|
|
||||||
WIPE_MASK,
|
|
||||||
} from "./motion"
|
|
||||||
|
|
||||||
const px = (value: number | string | undefined, fallback: number) => {
|
const px = (value: number | string | undefined, fallback: number) => {
|
||||||
if (typeof value === "number") return `${value}px`
|
if (typeof value === "number") return `${value}px`
|
||||||
@@ -26,11 +17,6 @@ const pct = (value: number | undefined, fallback: number) => {
|
|||||||
return `${v}%`
|
return `${v}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearWipe = (el: HTMLElement) => {
|
|
||||||
clearFadeStyles(el)
|
|
||||||
clearMaskStyles(el)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TextReveal(props: {
|
export function TextReveal(props: {
|
||||||
text?: string
|
text?: string
|
||||||
class?: string
|
class?: string
|
||||||
@@ -53,8 +39,10 @@ export function TextReveal(props: {
|
|||||||
let outRef: HTMLSpanElement | undefined
|
let outRef: HTMLSpanElement | undefined
|
||||||
let rootRef: HTMLSpanElement | undefined
|
let rootRef: HTMLSpanElement | undefined
|
||||||
let frame: number | undefined
|
let frame: number | undefined
|
||||||
|
|
||||||
const win = () => inRef?.scrollWidth ?? 0
|
const win = () => inRef?.scrollWidth ?? 0
|
||||||
const wout = () => outRef?.scrollWidth ?? 0
|
const wout = () => outRef?.scrollWidth ?? 0
|
||||||
|
|
||||||
const widen = (next: number) => {
|
const widen = (next: number) => {
|
||||||
if (next <= 0) return
|
if (next <= 0) return
|
||||||
if (props.growOnly ?? true) {
|
if (props.growOnly ?? true) {
|
||||||
@@ -63,14 +51,21 @@ export function TextReveal(props: {
|
|||||||
}
|
}
|
||||||
setWidth(`${next}px`)
|
setWidth(`${next}px`)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => props.text,
|
() => props.text,
|
||||||
(next, prev) => {
|
(next, prev) => {
|
||||||
if (next === prev) return
|
if (next === prev) return
|
||||||
|
if (typeof next === "string" && typeof prev === "string" && next.startsWith(prev)) {
|
||||||
|
setCur(next)
|
||||||
|
widen(win())
|
||||||
|
return
|
||||||
|
}
|
||||||
setSwapping(true)
|
setSwapping(true)
|
||||||
setOld(prev)
|
setOld(prev)
|
||||||
setCur(next)
|
setCur(next)
|
||||||
|
|
||||||
if (typeof requestAnimationFrame !== "function") {
|
if (typeof requestAnimationFrame !== "function") {
|
||||||
widen(Math.max(win(), wout()))
|
widen(Math.max(win(), wout()))
|
||||||
rootRef?.offsetHeight
|
rootRef?.offsetHeight
|
||||||
@@ -138,95 +133,3 @@ export function TextReveal(props: {
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextWipe(props: { text?: string; class?: string; delay?: number; animate?: boolean }) {
|
|
||||||
let ref: HTMLSpanElement | undefined
|
|
||||||
let frame: number | undefined
|
|
||||||
let anim: AnimationPlaybackControls | undefined
|
|
||||||
const reduce = useReducedMotion()
|
|
||||||
|
|
||||||
const run = () => {
|
|
||||||
if (props.animate === false) return
|
|
||||||
const el = ref
|
|
||||||
if (!el || !props.text || typeof window === "undefined") return
|
|
||||||
if (reduce()) return
|
|
||||||
|
|
||||||
const mask =
|
|
||||||
typeof CSS !== "undefined" &&
|
|
||||||
(CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
|
|
||||||
CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
|
|
||||||
|
|
||||||
anim?.stop()
|
|
||||||
if (frame !== undefined && typeof cancelAnimationFrame === "function") {
|
|
||||||
cancelAnimationFrame(frame)
|
|
||||||
frame = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
el.style.opacity = "0"
|
|
||||||
el.style.filter = "blur(3px)"
|
|
||||||
el.style.transform = "translateX(-0.06em)"
|
|
||||||
|
|
||||||
if (mask) {
|
|
||||||
el.style.maskImage = WIPE_MASK
|
|
||||||
el.style.webkitMaskImage = WIPE_MASK
|
|
||||||
el.style.maskSize = "240% 100%"
|
|
||||||
el.style.webkitMaskSize = "240% 100%"
|
|
||||||
el.style.maskRepeat = "no-repeat"
|
|
||||||
el.style.webkitMaskRepeat = "no-repeat"
|
|
||||||
el.style.maskPosition = "100% 0%"
|
|
||||||
el.style.webkitMaskPosition = "100% 0%"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof requestAnimationFrame !== "function") {
|
|
||||||
clearWipe(el)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
frame = requestAnimationFrame(() => {
|
|
||||||
frame = undefined
|
|
||||||
const node = ref
|
|
||||||
if (!node) return
|
|
||||||
anim = mask
|
|
||||||
? animate(
|
|
||||||
node,
|
|
||||||
{ opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
|
|
||||||
{ ...GROW_SPRING, delay: props.delay ?? 0 },
|
|
||||||
)
|
|
||||||
: animate(
|
|
||||||
node,
|
|
||||||
{ opacity: 1, filter: "blur(0px)", transform: "translateX(0)" },
|
|
||||||
{ ...GROW_SPRING, delay: props.delay ?? 0 },
|
|
||||||
)
|
|
||||||
|
|
||||||
anim?.finished.then(() => {
|
|
||||||
const value = ref
|
|
||||||
if (!value) return
|
|
||||||
clearWipe(value)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => [props.text, props.animate] as const,
|
|
||||||
([text, enabled]) => {
|
|
||||||
if (!text || enabled === false) {
|
|
||||||
if (ref) clearWipe(ref)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
run()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame)
|
|
||||||
anim?.stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span ref={ref} class={props.class} aria-label={props.text ?? ""}>
|
|
||||||
{props.text ?? "\u00A0"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
[data-component="text-shimmer"] {
|
[data-component="text-shimmer"] {
|
||||||
--text-shimmer-step: 45ms;
|
--text-shimmer-step: 45ms;
|
||||||
--text-shimmer-duration: 2000ms;
|
--text-shimmer-duration: 1200ms;
|
||||||
--text-shimmer-swap: 220ms;
|
--text-shimmer-swap: 220ms;
|
||||||
--text-shimmer-index: 0;
|
--text-shimmer-index: 0;
|
||||||
--text-shimmer-angle: 90deg;
|
--text-shimmer-angle: 90deg;
|
||||||
--text-shimmer-spread: 5.2ch;
|
--text-shimmer-spread: 5.2ch;
|
||||||
--text-shimmer-size: 600%;
|
--text-shimmer-size: 360%;
|
||||||
--text-shimmer-base-color: var(--text-weak);
|
--text-shimmer-base-color: var(--text-weak);
|
||||||
--text-shimmer-peak-color: var(--text-strong);
|
--text-shimmer-peak-color: var(--text-strong);
|
||||||
--text-shimmer-sweep: linear-gradient(
|
--text-shimmer-sweep: linear-gradient(
|
||||||
@@ -16,17 +16,15 @@
|
|||||||
);
|
);
|
||||||
--text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color));
|
--text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color));
|
||||||
|
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
vertical-align: baseline;
|
align-items: baseline;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
letter-spacing: inherit;
|
letter-spacing: inherit;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
|
[data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
|
||||||
display: inline-block;
|
display: inline-grid;
|
||||||
position: relative;
|
|
||||||
vertical-align: baseline;
|
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
letter-spacing: inherit;
|
letter-spacing: inherit;
|
||||||
@@ -35,7 +33,7 @@
|
|||||||
|
|
||||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"],
|
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"],
|
||||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||||
display: inline-block;
|
grid-area: 1 / 1;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
transition: opacity var(--text-shimmer-swap) ease-out;
|
transition: opacity var(--text-shimmer-swap) ease-out;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -44,14 +42,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
|
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
|
||||||
position: relative;
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
color: var(--text-weaker);
|
color: var(--text-weaker);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,16 +37,6 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
|
|||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
})
|
})
|
||||||
|
|
||||||
const len = createMemo(() => Math.max(text().length, 1))
|
|
||||||
const shimmerSize = createMemo(() => Math.max(300, Math.round(200 + 1400 / len())))
|
|
||||||
|
|
||||||
// duration = len × (size - 1) / velocity → uniform perceived sweep speed
|
|
||||||
const VELOCITY = 0.01375 // ch per ms, ~10% faster than original 0.0125 baseline
|
|
||||||
const shimmerDuration = createMemo(() => {
|
|
||||||
const s = shimmerSize() / 100
|
|
||||||
return Math.max(1000, Math.min(2500, Math.round((len() * (s - 1)) / VELOCITY)))
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={props.as ?? "span"}
|
component={props.as ?? "span"}
|
||||||
@@ -57,8 +47,6 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
|
|||||||
style={{
|
style={{
|
||||||
"--text-shimmer-swap": `${swap}ms`,
|
"--text-shimmer-swap": `${swap}ms`,
|
||||||
"--text-shimmer-index": `${offset()}`,
|
"--text-shimmer-index": `${offset()}`,
|
||||||
"--text-shimmer-size": `${shimmerSize()}%`,
|
|
||||||
"--text-shimmer-duration": `${shimmerDuration()}ms`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span data-slot="text-shimmer-char">
|
<span data-slot="text-shimmer-char">
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
/** Find the longest common character prefix between two strings. */
|
|
||||||
export function commonPrefix(a: string, b: string) {
|
|
||||||
const ac = Array.from(a)
|
|
||||||
const bc = Array.from(b)
|
|
||||||
let i = 0
|
|
||||||
while (i < ac.length && i < bc.length && ac[i] === bc[i]) i++
|
|
||||||
return {
|
|
||||||
prefix: ac.slice(0, i).join(""),
|
|
||||||
aSuffix: ac.slice(i).join(""),
|
|
||||||
bSuffix: bc.slice(i).join(""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function list<T>(value: T[] | undefined | null, fallback: T[]): T[] {
|
|
||||||
if (Array.isArray(value)) return value
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
@@ -27,10 +27,10 @@
|
|||||||
grid-template-columns: 0fr;
|
grid-template-columns: 0fr;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42));
|
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42));
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
transform: translateX(-0.04em);
|
transform: translateX(-0.04em);
|
||||||
transition-property: grid-template-columns, opacity, filter, transform;
|
transition-property: grid-template-columns, opacity, filter, transform;
|
||||||
transition-duration: 800ms, 400ms, 400ms, 800ms;
|
transition-duration: 250ms, 250ms, 250ms, 250ms;
|
||||||
transition-timing-function:
|
transition-timing-function:
|
||||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
|
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
|
||||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
[data-slot="tool-count-label-suffix-inner"] {
|
[data-slot="tool-count-label-suffix-inner"] {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { AnimatedNumber } from "./animated-number"
|
import { AnimatedNumber } from "./animated-number"
|
||||||
import { commonPrefix } from "./text-utils"
|
|
||||||
|
|
||||||
function split(text: string) {
|
function split(text: string) {
|
||||||
const match = /{{\s*count\s*}}/.exec(text)
|
const match = /{{\s*count\s*}}/.exec(text)
|
||||||
@@ -12,23 +11,35 @@ function split(text: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function common(one: string, other: string) {
|
||||||
|
const a = Array.from(one)
|
||||||
|
const b = Array.from(other)
|
||||||
|
let i = 0
|
||||||
|
while (i < a.length && i < b.length && a[i] === b[i]) i++
|
||||||
|
return {
|
||||||
|
stem: a.slice(0, i).join(""),
|
||||||
|
one: a.slice(i).join(""),
|
||||||
|
other: b.slice(i).join(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AnimatedCountLabel(props: { count: number; one: string; other: string; class?: string }) {
|
export function AnimatedCountLabel(props: { count: number; one: string; other: string; class?: string }) {
|
||||||
const one = createMemo(() => split(props.one))
|
const one = createMemo(() => split(props.one))
|
||||||
const other = createMemo(() => split(props.other))
|
const other = createMemo(() => split(props.other))
|
||||||
const singular = createMemo(() => Math.round(props.count) === 1)
|
const singular = createMemo(() => Math.round(props.count) === 1)
|
||||||
const active = createMemo(() => (singular() ? one() : other()))
|
const active = createMemo(() => (singular() ? one() : other()))
|
||||||
const suffix = createMemo(() => commonPrefix(one().after, other().after))
|
const suffix = createMemo(() => common(one().after, other().after))
|
||||||
const splitSuffix = createMemo(
|
const splitSuffix = createMemo(
|
||||||
() =>
|
() =>
|
||||||
one().before === other().before &&
|
one().before === other().before &&
|
||||||
(one().after.startsWith(other().after) || other().after.startsWith(one().after)),
|
(one().after.startsWith(other().after) || other().after.startsWith(one().after)),
|
||||||
)
|
)
|
||||||
const before = createMemo(() => (splitSuffix() ? one().before : active().before))
|
const before = createMemo(() => (splitSuffix() ? one().before : active().before))
|
||||||
const stem = createMemo(() => (splitSuffix() ? suffix().prefix : active().after))
|
const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after))
|
||||||
const tail = createMemo(() => {
|
const tail = createMemo(() => {
|
||||||
if (!splitSuffix()) return ""
|
if (!splitSuffix()) return ""
|
||||||
if (singular()) return suffix().aSuffix
|
if (singular()) return suffix().one
|
||||||
return suffix().bSuffix
|
return suffix().other
|
||||||
})
|
})
|
||||||
const showTail = createMemo(() => splitSuffix() && tail().length > 0)
|
const showTail = createMemo(() => splitSuffix() && tail().length > 0)
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,12 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
filter: blur(0);
|
filter: blur(0);
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
transform-origin: left center;
|
transform-origin: left center;
|
||||||
transition-property: grid-template-columns, opacity, filter, transform;
|
transition-property: grid-template-columns, opacity, filter, transform;
|
||||||
transition-duration:
|
transition-duration:
|
||||||
var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
|
var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 240ms), var(--tool-motion-fade-ms, 280ms),
|
||||||
var(--tool-motion-spring-ms, 800ms);
|
var(--tool-motion-spring-ms, 480ms);
|
||||||
transition-timing-function:
|
transition-timing-function:
|
||||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
|
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
|
||||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||||
@@ -35,12 +35,12 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
filter: blur(var(--tool-motion-blur, 2px));
|
filter: blur(var(--tool-motion-blur, 2px));
|
||||||
transform: translateY(0.06em) scale(0.985);
|
transform: translateY(0.06em) scale(0.985);
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
transform-origin: left center;
|
transform-origin: left center;
|
||||||
transition-property: grid-template-columns, opacity, filter, transform;
|
transition-property: grid-template-columns, opacity, filter, transform;
|
||||||
transition-duration:
|
transition-duration:
|
||||||
var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
|
var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 280ms), var(--tool-motion-fade-ms, 320ms),
|
||||||
var(--tool-motion-spring-ms, 800ms);
|
var(--tool-motion-spring-ms, 480ms);
|
||||||
transition-timing-function:
|
transition-timing-function:
|
||||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
|
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
|
||||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
[data-slot="tool-count-summary-empty-inner"] {
|
[data-slot="tool-count-summary-empty-inner"] {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +75,12 @@
|
|||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55));
|
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55));
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
transform: translateX(-0.08em);
|
transform: translateX(-0.08em);
|
||||||
transition-property: opacity, filter, transform;
|
transition-property: opacity, filter, transform;
|
||||||
transition-duration:
|
transition-duration:
|
||||||
var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms);
|
calc(var(--tool-motion-fade-ms, 200ms) * 0.75), calc(var(--tool-motion-fade-ms, 220ms) * 0.75),
|
||||||
|
calc(var(--tool-motion-fade-ms, 220ms) * 0.6);
|
||||||
transition-timing-function: ease-out, ease-out, ease-out;
|
transition-timing-function: ease-out, ease-out, ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,9 @@
|
|||||||
[data-slot="tool-status-swap"],
|
[data-slot="tool-status-swap"],
|
||||||
[data-slot="tool-status-tail"] {
|
[data-slot="tool-status-tail"] {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
overflow: clip;
|
overflow: hidden;
|
||||||
justify-items: start;
|
justify-items: start;
|
||||||
|
transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="tool-status-active"],
|
[data-slot="tool-status-active"],
|
||||||
@@ -30,8 +31,8 @@
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
transition-property: opacity, filter, transform;
|
transition-property: opacity, filter, transform;
|
||||||
transition-duration:
|
transition-duration:
|
||||||
var(--tool-motion-fade-ms, 400ms), calc(var(--tool-motion-fade-ms, 400ms) * 0.8),
|
var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8),
|
||||||
calc(var(--tool-motion-fade-ms, 400ms) * 0.8);
|
calc(var(--tool-motion-fade-ms, 240ms) * 0.8);
|
||||||
transition-timing-function: ease-out, ease-out, ease-out;
|
transition-timing-function: ease-out, ease-out, ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
|
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
|
||||||
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion"
|
|
||||||
import { TextShimmer } from "./text-shimmer"
|
import { TextShimmer } from "./text-shimmer"
|
||||||
import { commonPrefix } from "./text-utils"
|
|
||||||
|
function common(active: string, done: string) {
|
||||||
|
const a = Array.from(active)
|
||||||
|
const b = Array.from(done)
|
||||||
|
let i = 0
|
||||||
|
while (i < a.length && i < b.length && a[i] === b[i]) i++
|
||||||
|
return {
|
||||||
|
prefix: a.slice(0, i).join(""),
|
||||||
|
active: a.slice(i).join(""),
|
||||||
|
done: b.slice(i).join(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function contentWidth(el: HTMLSpanElement | undefined) {
|
function contentWidth(el: HTMLSpanElement | undefined) {
|
||||||
if (!el) return 0
|
if (!el) return 0
|
||||||
@@ -18,58 +27,25 @@ export function ToolStatusTitle(props: {
|
|||||||
class?: string
|
class?: string
|
||||||
split?: boolean
|
split?: boolean
|
||||||
}) {
|
}) {
|
||||||
const reduce = useReducedMotion()
|
const split = createMemo(() => common(props.activeText, props.doneText))
|
||||||
const split = createMemo(() => commonPrefix(props.activeText, props.doneText))
|
|
||||||
const suffix = createMemo(
|
const suffix = createMemo(
|
||||||
() =>
|
() => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0,
|
||||||
(props.split ?? true) && split().prefix.length >= 2 && split().aSuffix.length > 0 && split().bSuffix.length > 0,
|
|
||||||
)
|
)
|
||||||
const prefixLen = createMemo(() => Array.from(split().prefix).length)
|
const prefixLen = createMemo(() => Array.from(split().prefix).length)
|
||||||
const activeTail = createMemo(() => (suffix() ? split().aSuffix : props.activeText))
|
const activeTail = createMemo(() => (suffix() ? split().active : props.activeText))
|
||||||
const doneTail = createMemo(() => (suffix() ? split().bSuffix : props.doneText))
|
const doneTail = createMemo(() => (suffix() ? split().done : props.doneText))
|
||||||
|
|
||||||
|
const [width, setWidth] = createSignal("auto")
|
||||||
const [ready, setReady] = createSignal(false)
|
const [ready, setReady] = createSignal(false)
|
||||||
let activeRef: HTMLSpanElement | undefined
|
let activeRef: HTMLSpanElement | undefined
|
||||||
let doneRef: HTMLSpanElement | undefined
|
let doneRef: HTMLSpanElement | undefined
|
||||||
let swapRef: HTMLSpanElement | undefined
|
|
||||||
let tailRef: HTMLSpanElement | undefined
|
|
||||||
let frame: number | undefined
|
let frame: number | undefined
|
||||||
let readyFrame: number | undefined
|
let readyFrame: number | undefined
|
||||||
let widthAnim: AnimationPlaybackControls | undefined
|
|
||||||
|
|
||||||
const node = () => (suffix() ? tailRef : swapRef)
|
|
||||||
|
|
||||||
const setNodeWidth = (width: string) => {
|
|
||||||
if (swapRef) swapRef.style.width = width
|
|
||||||
if (tailRef) tailRef.style.width = width
|
|
||||||
}
|
|
||||||
|
|
||||||
const measure = () => {
|
const measure = () => {
|
||||||
const target = props.active ? activeRef : doneRef
|
const target = props.active ? activeRef : doneRef
|
||||||
const next = contentWidth(target)
|
const px = contentWidth(target)
|
||||||
if (next <= 0) return
|
if (px > 0) setWidth(`${px}px`)
|
||||||
|
|
||||||
const ref = node()
|
|
||||||
if (!ref || !ready() || reduce()) {
|
|
||||||
widthAnim?.stop()
|
|
||||||
setNodeWidth(`${next}px`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const prev = Math.max(0, Math.ceil(ref.getBoundingClientRect().width))
|
|
||||||
if (Math.abs(next - prev) < 1) {
|
|
||||||
ref.style.width = `${next}px`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.style.width = `${prev}px`
|
|
||||||
widthAnim?.stop()
|
|
||||||
widthAnim = animate(ref, { width: `${next}px` }, GROW_SPRING)
|
|
||||||
widthAnim.finished.then(() => {
|
|
||||||
const el = node()
|
|
||||||
if (!el) return
|
|
||||||
el.style.width = `${next}px`
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
@@ -114,7 +90,6 @@ export function ToolStatusTitle(props: {
|
|||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||||
if (readyFrame !== undefined) cancelAnimationFrame(readyFrame)
|
if (readyFrame !== undefined) cancelAnimationFrame(readyFrame)
|
||||||
widthAnim?.stop()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,7 +104,7 @@ export function ToolStatusTitle(props: {
|
|||||||
<Show
|
<Show
|
||||||
when={suffix()}
|
when={suffix()}
|
||||||
fallback={
|
fallback={
|
||||||
<span data-slot="tool-status-swap" ref={swapRef}>
|
<span data-slot="tool-status-swap" style={{ width: width() }}>
|
||||||
<span data-slot="tool-status-active" ref={activeRef}>
|
<span data-slot="tool-status-active" ref={activeRef}>
|
||||||
<TextShimmer text={activeTail()} active={props.active} offset={0} />
|
<TextShimmer text={activeTail()} active={props.active} offset={0} />
|
||||||
</span>
|
</span>
|
||||||
@@ -143,7 +118,7 @@ export function ToolStatusTitle(props: {
|
|||||||
<span data-slot="tool-status-prefix">
|
<span data-slot="tool-status-prefix">
|
||||||
<TextShimmer text={split().prefix} active={props.active} offset={0} />
|
<TextShimmer text={split().prefix} active={props.active} offset={0} />
|
||||||
</span>
|
</span>
|
||||||
<span data-slot="tool-status-tail" ref={tailRef}>
|
<span data-slot="tool-status-tail" style={{ width: width() }}>
|
||||||
<span data-slot="tool-status-active" ref={activeRef}>
|
<span data-slot="tool-status-active" ref={activeRef}>
|
||||||
<TextShimmer text={activeTail()} active={props.active} offset={prefixLen()} />
|
<TextShimmer text={activeTail()} active={props.active} offset={prefixLen()} />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
|
||||||
import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
|
|
||||||
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
import {
|
|
||||||
animate,
|
|
||||||
type AnimationPlaybackControls,
|
|
||||||
clearFadeStyles,
|
|
||||||
clearMaskStyles,
|
|
||||||
COLLAPSIBLE_SPRING,
|
|
||||||
GROW_SPRING,
|
|
||||||
WIPE_MASK,
|
|
||||||
} from "./motion"
|
|
||||||
|
|
||||||
export const TEXT_RENDER_THROTTLE_MS = 100
|
|
||||||
|
|
||||||
export function createThrottledValue(getValue: () => string) {
|
|
||||||
const [value, setValue] = createSignal(getValue())
|
|
||||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
||||||
let last = 0
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const next = getValue()
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
|
|
||||||
if (remaining <= 0) {
|
|
||||||
if (timeout) {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
timeout = undefined
|
|
||||||
}
|
|
||||||
last = now
|
|
||||||
setValue(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (timeout) clearTimeout(timeout)
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
last = Date.now()
|
|
||||||
setValue(next)
|
|
||||||
timeout = undefined
|
|
||||||
}, remaining)
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (timeout) clearTimeout(timeout)
|
|
||||||
})
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function busy(status: string | undefined) {
|
|
||||||
return status === "pending" || status === "running"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hold(state: () => boolean, wait = 2000) {
|
|
||||||
const [live, setLive] = createSignal(state())
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (state()) {
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
timer = undefined
|
|
||||||
setLive(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
timer = undefined
|
|
||||||
setLive(false)
|
|
||||||
}, wait)
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
})
|
|
||||||
|
|
||||||
return live
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateScrollMask(el: HTMLElement, fade = 12) {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = el
|
|
||||||
const overflow = scrollHeight - clientHeight
|
|
||||||
if (overflow <= 1) {
|
|
||||||
el.style.maskImage = ""
|
|
||||||
el.style.webkitMaskImage = ""
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const top = scrollTop > 1
|
|
||||||
const bottom = scrollTop < overflow - 1
|
|
||||||
const mask =
|
|
||||||
top && bottom
|
|
||||||
? `linear-gradient(to bottom, transparent 0, black ${fade}px, black calc(100% - ${fade}px), transparent 100%)`
|
|
||||||
: top
|
|
||||||
? `linear-gradient(to bottom, transparent 0, black ${fade}px)`
|
|
||||||
: bottom
|
|
||||||
? `linear-gradient(to bottom, black calc(100% - ${fade}px), transparent 100%)`
|
|
||||||
: ""
|
|
||||||
el.style.maskImage = mask
|
|
||||||
el.style.webkitMaskImage = mask
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCollapsible(options: {
|
|
||||||
content: () => HTMLElement | undefined
|
|
||||||
body: () => HTMLElement | undefined
|
|
||||||
open: () => boolean
|
|
||||||
measure?: () => number
|
|
||||||
onOpen?: () => void
|
|
||||||
}) {
|
|
||||||
const reduce = useReducedMotion()
|
|
||||||
let heightAnim: AnimationPlaybackControls | undefined
|
|
||||||
let fadeAnim: AnimationPlaybackControls | undefined
|
|
||||||
let gen = 0
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(options.open, (isOpen) => {
|
|
||||||
const content = options.content()
|
|
||||||
const body = options.body()
|
|
||||||
if (!content || !body) return
|
|
||||||
heightAnim?.stop()
|
|
||||||
fadeAnim?.stop()
|
|
||||||
if (reduce()) {
|
|
||||||
body.style.opacity = ""
|
|
||||||
body.style.filter = ""
|
|
||||||
if (isOpen) {
|
|
||||||
content.style.display = ""
|
|
||||||
content.style.height = "auto"
|
|
||||||
options.onOpen?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
content.style.height = "0px"
|
|
||||||
content.style.display = "none"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const id = ++gen
|
|
||||||
if (isOpen) {
|
|
||||||
content.style.display = ""
|
|
||||||
content.style.height = "0px"
|
|
||||||
body.style.opacity = "0"
|
|
||||||
body.style.filter = "blur(2px)"
|
|
||||||
fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (gen !== id) return
|
|
||||||
const c = options.content()
|
|
||||||
if (!c) return
|
|
||||||
const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
|
|
||||||
heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
|
|
||||||
heightAnim.finished.then(
|
|
||||||
() => {
|
|
||||||
if (gen !== id) return
|
|
||||||
c.style.height = "auto"
|
|
||||||
options.onOpen?.()
|
|
||||||
},
|
|
||||||
() => {},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const h = content.getBoundingClientRect().height
|
|
||||||
heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
|
|
||||||
fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
|
|
||||||
heightAnim.finished.then(
|
|
||||||
() => {
|
|
||||||
if (gen !== id) return
|
|
||||||
content.style.display = "none"
|
|
||||||
},
|
|
||||||
() => {},
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
++gen
|
|
||||||
heightAnim?.stop()
|
|
||||||
fadeAnim?.stop()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useContextToolPending(parts: () => ToolPart[], working?: () => boolean) {
|
|
||||||
const anyRunning = createMemo(() => parts().some((part) => busy(part.state.status)))
|
|
||||||
const [settled, setSettled] = createSignal(false)
|
|
||||||
createEffect(() => {
|
|
||||||
if (!anyRunning() && !working?.()) setSettled(true)
|
|
||||||
})
|
|
||||||
return createMemo(() => !settled() && (!!working?.() || anyRunning()))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRowWipe(opts: {
|
|
||||||
id: () => string
|
|
||||||
text: () => string | undefined
|
|
||||||
ref: () => HTMLElement | undefined
|
|
||||||
seen: Set<string>
|
|
||||||
}) {
|
|
||||||
const reduce = useReducedMotion()
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const id = opts.id()
|
|
||||||
const txt = opts.text()
|
|
||||||
const el = opts.ref()
|
|
||||||
if (!el) return
|
|
||||||
if (!txt) {
|
|
||||||
clearFadeStyles(el)
|
|
||||||
clearMaskStyles(el)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (reduce() || typeof window === "undefined") {
|
|
||||||
clearFadeStyles(el)
|
|
||||||
clearMaskStyles(el)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (opts.seen.has(id)) {
|
|
||||||
clearFadeStyles(el)
|
|
||||||
clearMaskStyles(el)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
opts.seen.add(id)
|
|
||||||
|
|
||||||
el.style.maskImage = WIPE_MASK
|
|
||||||
el.style.webkitMaskImage = WIPE_MASK
|
|
||||||
el.style.maskSize = "240% 100%"
|
|
||||||
el.style.webkitMaskSize = "240% 100%"
|
|
||||||
el.style.maskRepeat = "no-repeat"
|
|
||||||
el.style.webkitMaskRepeat = "no-repeat"
|
|
||||||
el.style.maskPosition = "100% 0%"
|
|
||||||
el.style.webkitMaskPosition = "100% 0%"
|
|
||||||
el.style.opacity = "0"
|
|
||||||
el.style.filter = "blur(2px)"
|
|
||||||
el.style.transform = "translateX(-0.06em)"
|
|
||||||
|
|
||||||
let done = false
|
|
||||||
const clear = () => {
|
|
||||||
if (done) return
|
|
||||||
done = true
|
|
||||||
clearFadeStyles(el)
|
|
||||||
clearMaskStyles(el)
|
|
||||||
}
|
|
||||||
if (typeof requestAnimationFrame !== "function") {
|
|
||||||
clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let anim: AnimationPlaybackControls | undefined
|
|
||||||
let frame: number | undefined = requestAnimationFrame(() => {
|
|
||||||
frame = undefined
|
|
||||||
const node = opts.ref()
|
|
||||||
if (!node) return
|
|
||||||
anim = animate(
|
|
||||||
node,
|
|
||||||
{
|
|
||||||
opacity: [0, 1],
|
|
||||||
filter: ["blur(2px)", "blur(0px)"],
|
|
||||||
transform: ["translateX(-0.06em)", "translateX(0)"],
|
|
||||||
maskPosition: "0% 0%",
|
|
||||||
},
|
|
||||||
GROW_SPRING,
|
|
||||||
)
|
|
||||||
|
|
||||||
anim.finished.catch(() => {}).finally(clear)
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (frame !== undefined) {
|
|
||||||
cancelAnimationFrame(frame)
|
|
||||||
clear()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useToolFade(
|
|
||||||
ref: () => HTMLElement | undefined,
|
|
||||||
options?: { delay?: number; wipe?: boolean; animate?: boolean },
|
|
||||||
) {
|
|
||||||
let anim: AnimationPlaybackControls | undefined
|
|
||||||
let frame: number | undefined
|
|
||||||
const delay = options?.delay ?? 0
|
|
||||||
const wipe = options?.wipe ?? false
|
|
||||||
const active = options?.animate !== false
|
|
||||||
const reduce = useReducedMotion()
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (!active) return
|
|
||||||
|
|
||||||
const el = ref()
|
|
||||||
if (!el || typeof window === "undefined") return
|
|
||||||
if (reduce()) return
|
|
||||||
|
|
||||||
const mask =
|
|
||||||
wipe &&
|
|
||||||
typeof CSS !== "undefined" &&
|
|
||||||
(CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
|
|
||||||
CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
|
|
||||||
|
|
||||||
el.style.opacity = "0"
|
|
||||||
el.style.filter = wipe ? "blur(3px)" : "blur(2px)"
|
|
||||||
el.style.transform = wipe ? "translateX(-0.06em)" : "translateY(0.04em)"
|
|
||||||
|
|
||||||
if (mask) {
|
|
||||||
el.style.maskImage = WIPE_MASK
|
|
||||||
el.style.webkitMaskImage = WIPE_MASK
|
|
||||||
el.style.maskSize = "240% 100%"
|
|
||||||
el.style.webkitMaskSize = "240% 100%"
|
|
||||||
el.style.maskRepeat = "no-repeat"
|
|
||||||
el.style.webkitMaskRepeat = "no-repeat"
|
|
||||||
el.style.maskPosition = "100% 0%"
|
|
||||||
el.style.webkitMaskPosition = "100% 0%"
|
|
||||||
}
|
|
||||||
|
|
||||||
frame = requestAnimationFrame(() => {
|
|
||||||
frame = undefined
|
|
||||||
const node = ref()
|
|
||||||
if (!node) return
|
|
||||||
|
|
||||||
anim = wipe
|
|
||||||
? mask
|
|
||||||
? animate(
|
|
||||||
node,
|
|
||||||
{ opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
|
|
||||||
{ ...GROW_SPRING, delay },
|
|
||||||
)
|
|
||||||
: animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, { ...GROW_SPRING, delay })
|
|
||||||
: animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...GROW_SPRING, delay })
|
|
||||||
|
|
||||||
anim?.finished.then(() => {
|
|
||||||
const value = ref()
|
|
||||||
if (!value) return
|
|
||||||
clearFadeStyles(value)
|
|
||||||
if (mask) clearMaskStyles(value)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
|
||||||
anim?.stop()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { createEffect, on, onCleanup } from "solid-js"
|
import { createEffect, on, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { animate, type AnimationPlaybackControls } from "motion"
|
|
||||||
import { FAST_SPRING } from "../components/motion"
|
|
||||||
|
|
||||||
export interface AutoScrollOptions {
|
export interface AutoScrollOptions {
|
||||||
working: () => boolean
|
working: () => boolean
|
||||||
@@ -11,28 +9,13 @@ export interface AutoScrollOptions {
|
|||||||
bottomThreshold?: number
|
bottomThreshold?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTLE_MS = 500
|
|
||||||
const AUTO_SCROLL_GRACE_MS = 120
|
|
||||||
const AUTO_SCROLL_EPSILON = 0.5
|
|
||||||
const MANUAL_ANCHOR_MS = 3000
|
|
||||||
const MANUAL_ANCHOR_QUIET_FRAMES = 24
|
|
||||||
|
|
||||||
export function createAutoScroll(options: AutoScrollOptions) {
|
export function createAutoScroll(options: AutoScrollOptions) {
|
||||||
let scroll: HTMLElement | undefined
|
let scroll: HTMLElement | undefined
|
||||||
let settling = false
|
let settling = false
|
||||||
let settleTimer: ReturnType<typeof setTimeout> | undefined
|
let settleTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let autoTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
let cleanup: (() => void) | undefined
|
let cleanup: (() => void) | undefined
|
||||||
let programmaticUntil = 0
|
let auto: { top: number; time: number } | undefined
|
||||||
let scrollAnim: AnimationPlaybackControls | undefined
|
|
||||||
let hold:
|
|
||||||
| {
|
|
||||||
el: HTMLElement
|
|
||||||
top: number
|
|
||||||
until: number
|
|
||||||
quiet: number
|
|
||||||
frame: number | undefined
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
|
|
||||||
const threshold = () => options.bottomThreshold ?? 10
|
const threshold = () => options.bottomThreshold ?? 10
|
||||||
|
|
||||||
@@ -44,160 +27,77 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
|||||||
const active = () => options.working() || settling
|
const active = () => options.working() || settling
|
||||||
|
|
||||||
const distanceFromBottom = (el: HTMLElement) => {
|
const distanceFromBottom = (el: HTMLElement) => {
|
||||||
// With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up
|
return el.scrollHeight - el.clientHeight - el.scrollTop
|
||||||
return Math.abs(el.scrollTop)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const canScroll = (el: HTMLElement) => {
|
const canScroll = (el: HTMLElement) => {
|
||||||
return el.scrollHeight - el.clientHeight > 1
|
return el.scrollHeight - el.clientHeight > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const markProgrammatic = () => {
|
// Browsers can dispatch scroll events asynchronously. If new content arrives
|
||||||
programmaticUntil = Date.now() + AUTO_SCROLL_GRACE_MS
|
// between us calling `scrollTo()` and the subsequent `scroll` event firing,
|
||||||
}
|
// the handler can see a non-zero `distanceFromBottom` and incorrectly assume
|
||||||
|
// the user scrolled.
|
||||||
const clearHold = () => {
|
const markAuto = (el: HTMLElement) => {
|
||||||
const next = hold
|
auto = {
|
||||||
if (!next) return
|
top: Math.max(0, el.scrollHeight - el.clientHeight),
|
||||||
if (next.frame !== undefined) cancelAnimationFrame(next.frame)
|
time: Date.now(),
|
||||||
hold = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const tickHold = () => {
|
|
||||||
const next = hold
|
|
||||||
const el = scroll
|
|
||||||
if (!next || !el) return false
|
|
||||||
if (Date.now() > next.until) {
|
|
||||||
clearHold()
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
if (!next.el.isConnected) {
|
|
||||||
clearHold()
|
if (autoTimer) clearTimeout(autoTimer)
|
||||||
|
autoTimer = setTimeout(() => {
|
||||||
|
auto = undefined
|
||||||
|
autoTimer = undefined
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuto = (el: HTMLElement) => {
|
||||||
|
const a = auto
|
||||||
|
if (!a) return false
|
||||||
|
|
||||||
|
if (Date.now() - a.time > 1500) {
|
||||||
|
auto = undefined
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = next.el.getBoundingClientRect().top
|
return Math.abs(el.scrollTop - a.top) < 2
|
||||||
if (!Number.isFinite(current)) {
|
|
||||||
clearHold()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = current - next.top
|
|
||||||
if (Math.abs(delta) <= AUTO_SCROLL_EPSILON) {
|
|
||||||
next.quiet += 1
|
|
||||||
if (next.quiet > MANUAL_ANCHOR_QUIET_FRAMES) {
|
|
||||||
clearHold()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
next.quiet = 0
|
|
||||||
if (!store.userScrolled) {
|
|
||||||
setStore("userScrolled", true)
|
|
||||||
options.onUserInteracted?.()
|
|
||||||
}
|
|
||||||
el.scrollTop += delta
|
|
||||||
markProgrammatic()
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleHold = () => {
|
const scrollToBottomNow = (behavior: ScrollBehavior) => {
|
||||||
const next = hold
|
|
||||||
if (!next) return
|
|
||||||
if (next.frame !== undefined) return
|
|
||||||
|
|
||||||
next.frame = requestAnimationFrame(() => {
|
|
||||||
const value = hold
|
|
||||||
if (!value) return
|
|
||||||
value.frame = undefined
|
|
||||||
if (!tickHold()) return
|
|
||||||
scheduleHold()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const preserve = (target: HTMLElement) => {
|
|
||||||
const el = scroll
|
const el = scroll
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
markAuto(el)
|
||||||
if (!store.userScrolled) {
|
if (behavior === "smooth") {
|
||||||
setStore("userScrolled", true)
|
el.scrollTo({ top: el.scrollHeight, behavior })
|
||||||
options.onUserInteracted?.()
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const top = target.getBoundingClientRect().top
|
// `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`.
|
||||||
if (!Number.isFinite(top)) return
|
el.scrollTop = el.scrollHeight
|
||||||
|
|
||||||
clearHold()
|
|
||||||
hold = {
|
|
||||||
el: target,
|
|
||||||
top,
|
|
||||||
until: Date.now() + MANUAL_ANCHOR_MS,
|
|
||||||
quiet: 0,
|
|
||||||
frame: undefined,
|
|
||||||
}
|
|
||||||
scheduleHold()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToBottom = (force: boolean) => {
|
const scrollToBottom = (force: boolean) => {
|
||||||
if (!force && !active()) return
|
if (!force && !active()) return
|
||||||
|
|
||||||
clearHold()
|
|
||||||
|
|
||||||
if (force && store.userScrolled) setStore("userScrolled", false)
|
if (force && store.userScrolled) setStore("userScrolled", false)
|
||||||
|
|
||||||
const el = scroll
|
const el = scroll
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
if (scrollAnim) cancelSmooth()
|
|
||||||
if (!force && store.userScrolled) return
|
if (!force && store.userScrolled) return
|
||||||
|
|
||||||
// With column-reverse, scrollTop=0 is at the bottom
|
const distance = distanceFromBottom(el)
|
||||||
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
|
if (distance < 2) {
|
||||||
markProgrammatic()
|
markAuto(el)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
el.scrollTop = 0
|
// For auto-following content we prefer immediate updates to avoid
|
||||||
markProgrammatic()
|
// visible "catch up" animations while content is still settling.
|
||||||
|
scrollToBottomNow("auto")
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelSmooth = () => {
|
const stop = () => {
|
||||||
if (scrollAnim) {
|
|
||||||
scrollAnim.stop()
|
|
||||||
scrollAnim = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const smoothScrollToBottom = () => {
|
|
||||||
const el = scroll
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
cancelSmooth()
|
|
||||||
if (store.userScrolled) setStore("userScrolled", false)
|
|
||||||
|
|
||||||
// With column-reverse, scrollTop=0 is at the bottom
|
|
||||||
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
|
|
||||||
markProgrammatic()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollAnim = animate(el.scrollTop, 0, {
|
|
||||||
...FAST_SPRING,
|
|
||||||
onUpdate: (v) => {
|
|
||||||
markProgrammatic()
|
|
||||||
el.scrollTop = v
|
|
||||||
},
|
|
||||||
onComplete: () => {
|
|
||||||
scrollAnim = undefined
|
|
||||||
markProgrammatic()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const stop = (input?: { hold?: boolean }) => {
|
|
||||||
if (input?.hold !== false) clearHold()
|
|
||||||
|
|
||||||
const el = scroll
|
const el = scroll
|
||||||
if (!el) return
|
if (!el) return
|
||||||
if (!canScroll(el)) {
|
if (!canScroll(el)) {
|
||||||
@@ -206,25 +106,15 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
|||||||
}
|
}
|
||||||
if (store.userScrolled) return
|
if (store.userScrolled) return
|
||||||
|
|
||||||
markProgrammatic()
|
|
||||||
setStore("userScrolled", true)
|
setStore("userScrolled", true)
|
||||||
options.onUserInteracted?.()
|
options.onUserInteracted?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleWheel = (e: WheelEvent) => {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
if (e.deltaY !== 0) clearHold()
|
|
||||||
|
|
||||||
if (e.deltaY > 0) {
|
|
||||||
const el = scroll
|
|
||||||
if (!el) return
|
|
||||||
if (distanceFromBottom(el) >= threshold()) return
|
|
||||||
if (store.userScrolled) setStore("userScrolled", false)
|
|
||||||
markProgrammatic()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.deltaY >= 0) return
|
if (e.deltaY >= 0) return
|
||||||
cancelSmooth()
|
// If the user is scrolling within a nested scrollable region (tool output,
|
||||||
|
// code block, etc), don't treat it as leaving the "follow bottom" mode.
|
||||||
|
// Those regions opt in via `data-scrollable`.
|
||||||
const el = scroll
|
const el = scroll
|
||||||
const target = e.target instanceof Element ? e.target : undefined
|
const target = e.target instanceof Element ? e.target : undefined
|
||||||
const nested = target?.closest("[data-scrollable]")
|
const nested = target?.closest("[data-scrollable]")
|
||||||
@@ -236,27 +126,23 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
|||||||
const el = scroll
|
const el = scroll
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
if (hold) {
|
|
||||||
if (Date.now() < programmaticUntil) return
|
|
||||||
clearHold()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canScroll(el)) {
|
if (!canScroll(el)) {
|
||||||
if (store.userScrolled) setStore("userScrolled", false)
|
if (store.userScrolled) setStore("userScrolled", false)
|
||||||
markProgrammatic()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (distanceFromBottom(el) < threshold()) {
|
if (distanceFromBottom(el) < threshold()) {
|
||||||
if (Date.now() < programmaticUntil) return
|
|
||||||
if (store.userScrolled) setStore("userScrolled", false)
|
if (store.userScrolled) setStore("userScrolled", false)
|
||||||
markProgrammatic()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!store.userScrolled && Date.now() < programmaticUntil) return
|
// Ignore scroll events triggered by our own scrollToBottom calls.
|
||||||
|
if (!store.userScrolled && isAuto(el)) {
|
||||||
|
scrollToBottom(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
stop({ hold: false })
|
stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInteraction = () => {
|
const handleInteraction = () => {
|
||||||
@@ -268,11 +154,6 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateOverflowAnchor = (el: HTMLElement) => {
|
const updateOverflowAnchor = (el: HTMLElement) => {
|
||||||
if (hold) {
|
|
||||||
el.style.overflowAnchor = "none"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode = options.overflowAnchor ?? "dynamic"
|
const mode = options.overflowAnchor ?? "dynamic"
|
||||||
|
|
||||||
if (mode === "none") {
|
if (mode === "none") {
|
||||||
@@ -292,17 +173,15 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
|||||||
() => store.contentRef,
|
() => store.contentRef,
|
||||||
() => {
|
() => {
|
||||||
const el = scroll
|
const el = scroll
|
||||||
if (hold) {
|
|
||||||
scheduleHold()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (el && !canScroll(el)) {
|
if (el && !canScroll(el)) {
|
||||||
if (store.userScrolled) setStore("userScrolled", false)
|
if (store.userScrolled) setStore("userScrolled", false)
|
||||||
markProgrammatic()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!active()) return
|
if (!active()) return
|
||||||
if (store.userScrolled) return
|
if (store.userScrolled) return
|
||||||
|
// ResizeObserver fires after layout, before paint.
|
||||||
|
// Keep the bottom locked in the same frame to avoid visible
|
||||||
|
// "jump up then catch up" artifacts while streaming content.
|
||||||
scrollToBottom(false)
|
scrollToBottom(false)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -321,11 +200,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
|||||||
settling = true
|
settling = true
|
||||||
settleTimer = setTimeout(() => {
|
settleTimer = setTimeout(() => {
|
||||||
settling = false
|
settling = false
|
||||||
}, SETTLE_MS)
|
}, 300)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
// Track `userScrolled` even before `scrollRef` is attached, so we can
|
||||||
|
// update overflow anchoring once the element exists.
|
||||||
store.userScrolled
|
store.userScrolled
|
||||||
const el = scroll
|
const el = scroll
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -334,8 +215,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
|||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (settleTimer) clearTimeout(settleTimer)
|
if (settleTimer) clearTimeout(settleTimer)
|
||||||
clearHold()
|
if (autoTimer) clearTimeout(autoTimer)
|
||||||
cancelSmooth()
|
|
||||||
if (cleanup) cleanup()
|
if (cleanup) cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -348,12 +228,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
|||||||
|
|
||||||
scroll = el
|
scroll = el
|
||||||
|
|
||||||
if (!el) {
|
if (!el) return
|
||||||
clearHold()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
markProgrammatic()
|
|
||||||
updateOverflowAnchor(el)
|
updateOverflowAnchor(el)
|
||||||
el.addEventListener("wheel", handleWheel, { passive: true })
|
el.addEventListener("wheel", handleWheel, { passive: true })
|
||||||
|
|
||||||
@@ -364,18 +240,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
|||||||
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
|
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
|
||||||
handleScroll,
|
handleScroll,
|
||||||
handleInteraction,
|
handleInteraction,
|
||||||
preserve,
|
|
||||||
pause: stop,
|
pause: stop,
|
||||||
forceScrollToBottom: () => scrollToBottom(true),
|
resume: () => {
|
||||||
smoothScrollToBottom,
|
|
||||||
snapToBottom: () => {
|
|
||||||
const el = scroll
|
|
||||||
if (!el) return
|
|
||||||
if (store.userScrolled) setStore("userScrolled", false)
|
if (store.userScrolled) setStore("userScrolled", false)
|
||||||
// With column-reverse, scrollTop=0 is at the bottom
|
scrollToBottom(true)
|
||||||
el.scrollTop = 0
|
|
||||||
markProgrammatic()
|
|
||||||
},
|
},
|
||||||
|
scrollToBottom: () => scrollToBottom(false),
|
||||||
|
forceScrollToBottom: () => scrollToBottom(true),
|
||||||
userScrolled: () => store.userScrolled,
|
userScrolled: () => store.userScrolled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export * from "./use-filtered-list"
|
export * from "./use-filtered-list"
|
||||||
export * from "./create-auto-scroll"
|
export * from "./create-auto-scroll"
|
||||||
export * from "./use-reduced-motion"
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { isHydrated } from "@solid-primitives/lifecycle"
|
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
|
||||||
import { createHydratableSingletonRoot } from "@solid-primitives/rootless"
|
|
||||||
|
|
||||||
const query = "(prefers-reduced-motion: reduce)"
|
|
||||||
|
|
||||||
export const useReducedMotion = createHydratableSingletonRoot(() => {
|
|
||||||
const value = createMediaQuery(query)
|
|
||||||
return () => !isHydrated() || value()
|
|
||||||
})
|
|
||||||
@@ -40,7 +40,6 @@
|
|||||||
@import "../components/progress-circle.css" layer(components);
|
@import "../components/progress-circle.css" layer(components);
|
||||||
@import "../components/radio-group.css" layer(components);
|
@import "../components/radio-group.css" layer(components);
|
||||||
@import "../components/resize-handle.css" layer(components);
|
@import "../components/resize-handle.css" layer(components);
|
||||||
@import "../components/rolling-results.css" layer(components);
|
|
||||||
@import "../components/select.css" layer(components);
|
@import "../components/select.css" layer(components);
|
||||||
@import "../components/spinner.css" layer(components);
|
@import "../components/spinner.css" layer(components);
|
||||||
@import "../components/switch.css" layer(components);
|
@import "../components/switch.css" layer(components);
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
|
|
||||||
if (a === b) return true
|
|
||||||
if (!a || !b) return false
|
|
||||||
if (a.length !== b.length) return false
|
|
||||||
return a.every((x, i) => x === b[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findLast<T>(
|
export function findLast<T>(
|
||||||
items: readonly T[],
|
items: readonly T[],
|
||||||
predicate: (item: T, index: number, items: readonly T[]) => boolean,
|
predicate: (item: T, index: number, items: readonly T[]) => boolean,
|
||||||
|
|||||||
Reference in New Issue
Block a user