mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
fix(app): show an opened session's model right away (#57131)
This commit is contained in:
committed by
GitHub
Unverified
parent
ee04c57d05
commit
781a9f66e4
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { NO_PROVIDER, T0, provider, sessionHref } from "../utils/app"
|
||||
import { NO_PROVIDER, SERVER, T0, holdRoute, provider, sessionHref } from "../utils/app"
|
||||
import { mockWorkspace, openDraft, openSession } from "../utils/workspace"
|
||||
|
||||
test.use({ permissions: ["clipboard-read", "clipboard-write"] })
|
||||
@@ -87,7 +87,7 @@ test("a catalog refresh cannot replace an existing session's model", async ({ pa
|
||||
])
|
||||
})
|
||||
|
||||
test("an existing session's model waits for its agent instead of showing a fallback", async ({ page }) => {
|
||||
test("an existing session's model never shows a fallback while its agent loads", async ({ page }) => {
|
||||
const sessionID = "ses_agent_pending"
|
||||
await mockWorkspace(page, {
|
||||
name: "AgentPending",
|
||||
@@ -101,21 +101,106 @@ test("an existing session's model waits for its agent instead of showing a fallb
|
||||
await route.fallback()
|
||||
})
|
||||
|
||||
// Every chip text painted from the first frame, so a fallback shown for even one frame fails the test.
|
||||
await page.addInitScript(() => {
|
||||
const seen = new Set<string>()
|
||||
window.__chipTexts = seen
|
||||
|
||||
const tick = () => {
|
||||
const text = document.querySelector('[data-action="composer-model"]')?.textContent
|
||||
|
||||
if (text) seen.add(text)
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick)
|
||||
})
|
||||
|
||||
const catalog = ["/api/model", "/api/provider", "/api/config"].map((path) =>
|
||||
page.waitForResponse((response) => new URL(response.url()).pathname === path),
|
||||
)
|
||||
|
||||
const agentRequested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/agent")
|
||||
await page.goto(sessionHref(sessionID))
|
||||
await Promise.all(catalog)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
|
||||
const control = page.locator('[data-action="composer-model"]')
|
||||
// The recent model must not stand in for the session's model while its agent is unresolved.
|
||||
await expect(control).toBeHidden()
|
||||
// The recent model must not stand in for the session's model while its agent is unresolved. The chip may stay
|
||||
// hidden or name the session's own model, which the loaded selection then keeps.
|
||||
await agentRequested
|
||||
const agentLoaded = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/agent")
|
||||
agents.resolve()
|
||||
await agentLoaded
|
||||
await expect(control).toHaveText("OpenCode / Alpha")
|
||||
expect(await page.evaluate(() => [...(window.__chipTexts ?? [])])).toEqual(["OpenCode / Alpha"])
|
||||
})
|
||||
|
||||
test("an opened session in another Location names its model from a loaded catalog before its agents load", async ({
|
||||
page,
|
||||
}) => {
|
||||
const other = "C:/OpenCode/WarmOther"
|
||||
await mockWorkspace(page, {
|
||||
name: "WarmOpen",
|
||||
sessions: [
|
||||
{ id: "ses_warm_here", title: "Here", agent: "build", model: { id: "alpha", providerID: "opencode" } },
|
||||
{
|
||||
id: "ses_warm_there",
|
||||
title: "There",
|
||||
directory: other,
|
||||
location: { directory: other },
|
||||
agent: "build",
|
||||
model: { id: "beta", providerID: "opencode", variant: "high" },
|
||||
},
|
||||
],
|
||||
provider: provider({ id: "alpha", name: "Alpha" }, { id: "beta", name: "Beta (latest)", variants: { high: {} } }),
|
||||
seed: { settings: { appearance: { tabLayout: "vertical" } } },
|
||||
})
|
||||
|
||||
const control = page.locator('[data-action="composer-model"]')
|
||||
await page.goto(sessionHref("ses_warm_here"))
|
||||
await expect(control).toHaveText("OpenCode / Alpha")
|
||||
|
||||
const held = await holdRoute(
|
||||
page,
|
||||
(url) =>
|
||||
["/api/agent", "/api/config"].includes(url.pathname) && url.searchParams.get("location[directory]") === other,
|
||||
)
|
||||
|
||||
try {
|
||||
await page.locator(`[data-slot="vertical-tabs"] a[href="${sessionHref("ses_warm_there", SERVER)}"]`).click()
|
||||
await held.arrived
|
||||
await expect(control).toHaveText("OpenCode / Beta")
|
||||
await expect(page.getByRole("button", { name: "Choose model variant" })).toHaveText("high")
|
||||
const painted = await control.elementHandle()
|
||||
|
||||
const loaded = ["/api/agent", "/api/config"].map((path) =>
|
||||
page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
|
||||
return url.pathname === path && url.searchParams.get("location[directory]") === other
|
||||
}),
|
||||
)
|
||||
|
||||
// A click before the catalog loads opens the dialog once it does.
|
||||
await control.click()
|
||||
held.release()
|
||||
await Promise.all(loaded)
|
||||
await expect(page.getByRole("dialog")).toBeVisible()
|
||||
// The loaded selection keeps the same chip instead of replacing it.
|
||||
await expect(control).toHaveText("OpenCode / Beta")
|
||||
expect(await painted?.evaluate((element) => element.isConnected)).toBe(true)
|
||||
} finally {
|
||||
held.release()
|
||||
}
|
||||
})
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__chipTexts?: Set<string>
|
||||
}
|
||||
}
|
||||
|
||||
test("names a Console-managed provider without its workspace unless a direct connection shares it", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -375,6 +460,7 @@ test("keeps focus in a dialog that replaces the model dialog and returns it to t
|
||||
name: "ComposerDialogFocus",
|
||||
provider: provider({ id: "paid", name: "Paid", cost: { input: 1, output: 1 } }),
|
||||
})
|
||||
|
||||
await editor.fill("keep me")
|
||||
await editor.press("End")
|
||||
await editor.press("ArrowLeft")
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
draftHref,
|
||||
expectPath,
|
||||
holdRoute,
|
||||
pageMessagesFrom,
|
||||
project,
|
||||
seed,
|
||||
session,
|
||||
@@ -369,10 +370,13 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
|
||||
const actions = await Promise.all(
|
||||
["vertical-tabs-home", "vertical-tabs-search", "vertical-tabs-new-session"].map(async (action) => {
|
||||
const box = await sidebar.locator(`[data-action="${action}"]`).boundingBox()
|
||||
|
||||
if (!box) throw new Error(`${action} is not rendered`)
|
||||
|
||||
return box
|
||||
}),
|
||||
)
|
||||
|
||||
const gaps = actions.slice(1).map((box, index) => box.y - (actions[index].y + actions[index].height))
|
||||
expect(gaps).toEqual([4, 4])
|
||||
|
||||
@@ -1075,3 +1079,45 @@ async function twoServers(
|
||||
tabs: [input.a[0]!.id, { session: input.b[0]!.id, server: REMOTE_SERVER }],
|
||||
})
|
||||
}
|
||||
|
||||
test("loads a tab's transcript while the pointer rests on it, and not when it passes over", async ({ page }) => {
|
||||
const requested: string[] = []
|
||||
page.on("request", (request) => {
|
||||
const id = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)?.[1]
|
||||
|
||||
if (id && request.method() === "GET") requested.push(id)
|
||||
})
|
||||
|
||||
await mockWorkspace(page, {
|
||||
name: "HoverPrefetch",
|
||||
sessions: [a, b, c],
|
||||
pageMessages: pageMessagesFrom(
|
||||
Object.fromEntries(
|
||||
[a, b, c].map((item): [string, SessionMessageInfo[]] => [
|
||||
item.id,
|
||||
[{ id: `msg_${item.id}`, type: "user", time: { created: 1700000000000 }, text: item.title }],
|
||||
]),
|
||||
),
|
||||
),
|
||||
seed: { settings: { appearance: { tabLayout: "vertical" } } },
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
await expectSessionTitle(page, a.title)
|
||||
const tab = (id: string) => page.locator(`[data-slot="vertical-tabs"] a[href="${sessionHref(id, SERVER)}"]`)
|
||||
const message = (id: string) => page.locator(`[data-timeline-virtual-content] [data-message-id="msg_${id}"]`)
|
||||
await expect(message(a.id)).toBeVisible()
|
||||
|
||||
// Raw moves keep the pass well under the dwell, which hover() actionability checks can exceed on slow runners.
|
||||
const [passed, rest] = await Promise.all([tab(c.id).boundingBox(), tab(a.id).boundingBox()])
|
||||
|
||||
await page.mouse.move(passed!.x + passed!.width / 2, passed!.y + passed!.height / 2)
|
||||
await page.mouse.move(rest!.x + rest!.width / 2, rest!.y + rest!.height / 2)
|
||||
await tab(b.id).hover()
|
||||
await expect.poll(() => requested.includes(b.id)).toBe(true)
|
||||
// A dwell started by the pass would have fired before the one on the rested tab.
|
||||
expect(requested).not.toContain(c.id)
|
||||
|
||||
await tab(b.id).click()
|
||||
await expect(message(b.id)).toBeVisible()
|
||||
expect(requested.filter((id) => id === b.id)).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -20,6 +20,8 @@ export type ComposerControls = {
|
||||
providerName?: string
|
||||
paid: boolean
|
||||
loading: boolean
|
||||
/** While loading, the expected model's names from a catalog already loaded for another Location. */
|
||||
preview?: { provider?: string; model: string; variant: string; variants: string[] }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
@@ -27,6 +27,9 @@ export function Composer(props: {
|
||||
const language = useLanguage()
|
||||
|
||||
const modelName = () => {
|
||||
const preview = props.model.model.preview
|
||||
|
||||
if (preview) return preview.model
|
||||
const selection = props.model.model.selection
|
||||
const model = selection.current()
|
||||
|
||||
@@ -45,7 +48,7 @@ export function Composer(props: {
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
readOnly={props.readOnly}
|
||||
class={props.class}
|
||||
modelControlsVisible={!props.model.model.loading}
|
||||
modelControlsVisible={!props.model.model.loading || !!props.model.model.preview}
|
||||
attachKeybind={command.keybindParts("file.attach")}
|
||||
attachShortcut={command.keybind("file.attach")}
|
||||
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
|
||||
@@ -54,11 +57,12 @@ export function Composer(props: {
|
||||
modelControl={
|
||||
<ComposerModelControl
|
||||
loading={props.model.model.loading}
|
||||
preview={!!props.model.model.preview}
|
||||
paid={props.model.model.paid}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybindParts("model.choose")}
|
||||
model={props.model.model.selection}
|
||||
providerName={props.model.model.providerName}
|
||||
providerName={props.model.model.preview?.provider ?? props.model.model.providerName}
|
||||
modelName={modelName()}
|
||||
onClose={props.model.restoreFocus}
|
||||
onUnpaidClick={() => dialog.show(() => <DialogSelectModelUnpaid model={props.model.model.selection} />)}
|
||||
@@ -71,6 +75,7 @@ export function Composer(props: {
|
||||
|
||||
function ComposerModelControl(props: {
|
||||
loading: boolean
|
||||
preview: boolean
|
||||
paid: boolean
|
||||
title: string
|
||||
keybind: string[]
|
||||
@@ -80,11 +85,30 @@ function ComposerModelControl(props: {
|
||||
onClose: () => void
|
||||
onUnpaidClick: () => void
|
||||
}) {
|
||||
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
|
||||
// A preview paints the chip in its resting state, and the loaded names replace it in place.
|
||||
const shouldAnimate = createMemo<boolean>((previous) => previous ?? (props.loading && !props.preview))
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const dialog = useDialog()
|
||||
const managed = createMemo(() => consoleModelGroup(props.model.list()))
|
||||
|
||||
const open = (detail: number) => {
|
||||
if (!props.paid) return props.onUnpaidClick()
|
||||
// A pointer open keeps focus in the composer, so the dialog returns it to the caret. A keyboard open
|
||||
// (a click with no pointer presses) returns focus to this trigger instead of moving it to the editor.
|
||||
void dialog.show(() => <DialogSelectModel model={props.model} />, detail === 0 ? undefined : props.onClose)
|
||||
}
|
||||
|
||||
// A previewed chip is visible before the catalog loads, so a click on it opens the dialog once loading finishes.
|
||||
const [deferred, setDeferred] = createSignal<number>()
|
||||
|
||||
createEffect(() => {
|
||||
const detail = deferred()
|
||||
|
||||
if (props.loading || detail === undefined) return
|
||||
setDeferred(undefined)
|
||||
open(detail)
|
||||
})
|
||||
|
||||
const providerName = (provider: { id: string; name: string }) => {
|
||||
const group = managed()
|
||||
|
||||
@@ -113,7 +137,7 @@ function ComposerModelControl(props: {
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={!props.loading}>
|
||||
<Show when={!props.loading || props.preview}>
|
||||
<Tooltip
|
||||
inactive={mobile()}
|
||||
contentClass={props.model.current() ? "!p-0 rounded-md" : undefined}
|
||||
@@ -141,8 +165,9 @@ function ComposerModelControl(props: {
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{/* While loading, the drawer would open on a catalog that isn't there yet, so the inert chip stands in. */}
|
||||
<Show
|
||||
when={mobile()}
|
||||
when={mobile() && !props.loading}
|
||||
fallback={
|
||||
<Button
|
||||
data-action="composer-model"
|
||||
@@ -154,13 +179,10 @@ function ComposerModelControl(props: {
|
||||
style={{ height: "28px" }}
|
||||
onMouseDown={(event: MouseEvent) => event.preventDefault()}
|
||||
onClick={(event: MouseEvent) => {
|
||||
if (!props.paid) return props.onUnpaidClick()
|
||||
// A pointer open keeps focus in the composer, so the dialog returns it to the caret. A keyboard open
|
||||
// (a click with no pointer presses) returns focus to this trigger instead of moving it to the editor.
|
||||
void dialog.show(
|
||||
() => <DialogSelectModel model={props.model} />,
|
||||
event.detail === 0 ? undefined : props.onClose,
|
||||
)
|
||||
if (!props.loading) return open(event.detail)
|
||||
|
||||
// On mobile this inert chip stands in for the drawer trigger, so a click there is dropped.
|
||||
if (!mobile()) setDeferred(event.detail)
|
||||
}}
|
||||
>
|
||||
{content()}
|
||||
|
||||
@@ -351,9 +351,11 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
const existing = pathImages.get(path)
|
||||
|
||||
if (existing) return existing
|
||||
|
||||
const url = readLocalImage(serverSDK.api, sdk().directory, path, pathImageReads.signal)
|
||||
.then((blob) => blob && URL.createObjectURL(blob))
|
||||
.catch(() => undefined)
|
||||
|
||||
pathImages.set(path, url)
|
||||
// A failed read retries the next time the attachment shows.
|
||||
void url.then((value) => value || pathImages.delete(path))
|
||||
@@ -428,9 +430,20 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
: undefined
|
||||
},
|
||||
variant: {
|
||||
options: () => variants().map((value) => ({ id: value, label: value })),
|
||||
current: () => adapter.controls().model.selection.variant.current() ?? "default",
|
||||
onSelect: (value) => adapter.controls().model.selection.variant.set(value === "default" ? undefined : value),
|
||||
options: () => {
|
||||
const preview = adapter.controls().model.preview
|
||||
|
||||
return (preview ? ["default", ...preview.variants] : variants()).map((value) => ({ id: value, label: value }))
|
||||
},
|
||||
current: () =>
|
||||
adapter.controls().model.preview?.variant ??
|
||||
adapter.controls().model.selection.variant.current() ??
|
||||
"default",
|
||||
onSelect: (value) => {
|
||||
if (adapter.controls().model.loading) return
|
||||
|
||||
adapter.controls().model.selection.variant.set(value === "default" ? undefined : value)
|
||||
},
|
||||
keybind: () => command.keybindParts("model.variant.cycle"),
|
||||
},
|
||||
submit: {
|
||||
|
||||
@@ -5,29 +5,31 @@ import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
|
||||
import { normalizeAgentList, normalizeProviderList } from "@/runtime/server/global-sync/utils"
|
||||
import { useModels } from "@/providers/models/models"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant"
|
||||
import { useComposerState } from "./persistence"
|
||||
import { useConfiguredModel } from "@/providers/models/configured"
|
||||
import { consoleProviderGroup, consoleProviderName } from "@/providers/catalog/console"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
|
||||
export function createComposerControls(input: { model?: ModelSelection } = {}) {
|
||||
const local = useLocal()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const models = useGlobal().models
|
||||
|
||||
const canonical = (provider: { id: string; canonical?: string }) =>
|
||||
provider.canonical ?? provider.id.replace(/^console-/, "")
|
||||
|
||||
// Like the model picker, a Console-managed provider drops its "Workspace / " prefix unless the
|
||||
// same provider is also connected directly and the workspace is what tells them apart.
|
||||
const providerName = (providerID: string) => {
|
||||
const provider = providers.all().get(providerID)
|
||||
const providerName = (providerID: string, catalog = providers.all()) => {
|
||||
const provider = catalog.get(providerID)
|
||||
|
||||
if (!provider) return providerID
|
||||
const all = [...providers.all().values()]
|
||||
const all = [...catalog.values()]
|
||||
const managed = consoleProviderGroup(all)
|
||||
|
||||
if (!managed?.providers.includes(provider)) return provider.name
|
||||
@@ -36,6 +38,52 @@ export function createComposerControls(input: { model?: ModelSelection } = {}) {
|
||||
return direct ? provider.name : consoleProviderName(managed, provider.name)
|
||||
}
|
||||
|
||||
const loading = () => {
|
||||
const selection = input.model ?? local.model
|
||||
|
||||
return (
|
||||
!selection.ready() || data.location.agent.list({ directory: sdk().directory }) === undefined || !providers.ready()
|
||||
)
|
||||
}
|
||||
|
||||
// Until this Location's catalog loads, the default Location's or one visited earlier nearly always lists the same
|
||||
// model, so it names the chips; the loaded names then replace them in place.
|
||||
const preview = createMemo(() => {
|
||||
if (!loading()) return
|
||||
const expected = (input.model ?? local.model).preview?.()
|
||||
|
||||
if (!expected) return
|
||||
|
||||
const catalogs = providers.ready()
|
||||
? [providers.all()]
|
||||
: [undefined, ...data.session.list().map((item) => item.location)].map((location) => {
|
||||
const provider = data.location.provider.list(location)
|
||||
const catalog = data.location.model.list(location)
|
||||
|
||||
return provider && catalog ? normalizeProviderList(provider, catalog).all : undefined
|
||||
})
|
||||
|
||||
const match = catalogs
|
||||
.map((all) => all && { all, model: all.get(expected.model.providerID)?.models[expected.model.modelID] })
|
||||
.find((item) => !!item?.model)
|
||||
|
||||
if (!match?.model) return
|
||||
const variants = Object.keys(match.model.variants ?? {})
|
||||
|
||||
return {
|
||||
provider: providerName(expected.model.providerID, match.all),
|
||||
model: match.model.name.replace("(latest)", "").trim(),
|
||||
variants,
|
||||
variant:
|
||||
resolveModelVariant({
|
||||
variants,
|
||||
selected: expected.selected,
|
||||
configured: undefined,
|
||||
preferred: models.store.variant?.[`${expected.model.providerID}/${expected.model.modelID}`],
|
||||
}) ?? "default",
|
||||
}
|
||||
})
|
||||
|
||||
return createMemo<ComposerControls>(() => {
|
||||
const selection = input.model ?? local.model
|
||||
const key = selection.key()
|
||||
@@ -54,10 +102,8 @@ export function createComposerControls(input: { model?: ModelSelection } = {}) {
|
||||
providerName: key && providerName(key.providerID),
|
||||
paid: providers.paid().length > 0,
|
||||
// The session's model applies only to its agent, so resolve agents before showing a model.
|
||||
loading:
|
||||
!selection.ready() ||
|
||||
data.location.agent.list({ directory: sdk().directory }) === undefined ||
|
||||
!providers.ready(),
|
||||
loading: loading(),
|
||||
preview: preview(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -290,6 +290,23 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return models.find(item)
|
||||
})
|
||||
|
||||
// What `key` and `selected` will resolve to once the Location's agents and config load, read from state already
|
||||
// here: a local draft, else the Session's durable model unless the draft picked another agent.
|
||||
const preview = () => {
|
||||
if (!savedReady()) return
|
||||
const draft = scope()
|
||||
|
||||
if (draft?.model) return { model: draft.model, selected: draft.variant }
|
||||
const session = durable()
|
||||
|
||||
if (!session?.model || (draft?.agent && draft.agent !== session.agent)) return
|
||||
|
||||
return {
|
||||
model: { providerID: session.model.providerID, modelID: session.model.id },
|
||||
selected: session.model.variant ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const configured = (model = current()) => {
|
||||
const item = agent.current()
|
||||
|
||||
@@ -399,6 +416,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const model = {
|
||||
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
|
||||
key,
|
||||
preview,
|
||||
current,
|
||||
recent,
|
||||
favorite: { list: favorite, toggle: models.favorite.toggle },
|
||||
@@ -561,5 +579,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
})
|
||||
|
||||
export type ModelSelection = Omit<ReturnType<typeof useLocal>["model"], "trackSessionCommit"> &
|
||||
Partial<Pick<ReturnType<typeof useLocal>["model"], "trackSessionCommit">>
|
||||
export type ModelSelection = Omit<ReturnType<typeof useLocal>["model"], "trackSessionCommit" | "preview"> &
|
||||
Partial<Pick<ReturnType<typeof useLocal>["model"], "trackSessionCommit" | "preview">>
|
||||
|
||||
@@ -35,6 +35,7 @@ function SessionTabSlot(props: {
|
||||
onRename: (title: string) => Promise<void>
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
onIntent: (hovered: boolean) => void
|
||||
}) {
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
@@ -59,6 +60,8 @@ function SessionTabSlot(props: {
|
||||
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
|
||||
"w-full shrink-0": props.orientation === "vertical",
|
||||
}}
|
||||
onPointerEnter={() => props.onIntent(true)}
|
||||
onPointerLeave={() => props.onIntent(false)}
|
||||
>
|
||||
<TabNavItem
|
||||
ref={(el) => {
|
||||
@@ -176,6 +179,20 @@ function SessionTabEntry(props: {
|
||||
onCleanup(() => window.clearTimeout(timer))
|
||||
})
|
||||
|
||||
// A pointer resting on a tab usually clicks it next, so its transcript loads during the dwell. Passing over loads nothing.
|
||||
let intent: number | undefined
|
||||
|
||||
const hover = (hovered: boolean) => {
|
||||
window.clearTimeout(intent)
|
||||
const ctx = props.serverCtx
|
||||
const value = session()
|
||||
|
||||
if (!hovered || !ctx || !value || props.active || ctx.sdk.connection.status() !== "connected") return
|
||||
intent = window.setTimeout(() => void ctx.data.session.message.sync(value.id).catch(() => undefined), 100)
|
||||
}
|
||||
|
||||
onCleanup(() => window.clearTimeout(intent))
|
||||
|
||||
createEffect(() => {
|
||||
const value = session()
|
||||
|
||||
@@ -215,6 +232,7 @@ function SessionTabEntry(props: {
|
||||
onRename={rename}
|
||||
onNavigate={props.onNavigate}
|
||||
onClose={props.onClose}
|
||||
onIntent={hover}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user