mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 19:09:49 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68a9432584 | |||
| fec51424f7 | |||
| 1084cd96ac | |||
| 5c41857525 | |||
| 3709f04748 | |||
| b27da40a48 |
@@ -0,0 +1,132 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/RequestDocks"
|
||||||
|
const projectID = "proj_request_docks"
|
||||||
|
const sessionID = "ses_request_docks"
|
||||||
|
const title = "Request dock regression"
|
||||||
|
|
||||||
|
test("shows a pending question dock", async ({ page }) => {
|
||||||
|
await mockServer(page, {
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "question-request",
|
||||||
|
sessionID,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Implementation",
|
||||||
|
question: "Which implementation should be used?",
|
||||||
|
options: [
|
||||||
|
{ label: "Minimal", description: "Use the smallest correct change" },
|
||||||
|
{ label: "Extended", description: "Include additional behavior" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||||
|
await expect(question).toBeVisible()
|
||||||
|
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
|
||||||
|
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
|
||||||
|
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
|
||||||
|
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
|
||||||
|
|
||||||
|
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||||
|
const reply = page.waitForRequest(
|
||||||
|
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
||||||
|
)
|
||||||
|
await question.getByRole("button", { name: "Submit" }).click()
|
||||||
|
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shows a pending permission dock", async ({ page }) => {
|
||||||
|
await mockServer(page, {
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: "permission-request",
|
||||||
|
sessionID,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["git status", "git diff"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
|
||||||
|
await expect(permission).toBeVisible()
|
||||||
|
await expect(permission.getByText("git status")).toBeVisible()
|
||||||
|
await expect(permission.getByText("git diff")).toBeVisible()
|
||||||
|
await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3)
|
||||||
|
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
|
||||||
|
|
||||||
|
const reply = page.waitForRequest((request) => request.method() === "POST")
|
||||||
|
await permission.getByRole("button", { name: "Allow once" }).click()
|
||||||
|
const request = await reply
|
||||||
|
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
|
||||||
|
expect(request.postDataJSON()).toEqual({ response: "once" })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function mockServer(
|
||||||
|
page: Page,
|
||||||
|
requests: {
|
||||||
|
permissions?: unknown[] | (() => unknown[])
|
||||||
|
questions?: unknown[] | (() => unknown[])
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "request-docks",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: {
|
||||||
|
"claude-opus-4-6": {
|
||||||
|
id: "claude-opus-4-6",
|
||||||
|
name: "Claude Opus 4.6",
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: "request-docks",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
permissions: requests.permissions,
|
||||||
|
questions: requests.questions,
|
||||||
|
})
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/TodoDockNavigation"
|
||||||
|
const projectID = "proj_todo_dock_navigation"
|
||||||
|
const sourceID = "ses_todo_dock_source"
|
||||||
|
const otherID = "ses_todo_dock_other"
|
||||||
|
const sourceTitle = "Todo dock animation"
|
||||||
|
const otherTitle = "Separate session"
|
||||||
|
|
||||||
|
const activeTodos = [
|
||||||
|
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
||||||
|
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
||||||
|
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
||||||
|
]
|
||||||
|
|
||||||
|
type EventPayload = {
|
||||||
|
directory: string
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
||||||
|
|
||||||
|
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000)
|
||||||
|
const events: EventPayload[] = []
|
||||||
|
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||||
|
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "todo-dock-navigation",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: {
|
||||||
|
"claude-opus-4-6": {
|
||||||
|
id: "claude-opus-4-6",
|
||||||
|
name: "Claude Opus 4.6",
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
|
},
|
||||||
|
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
events: () => events.splice(0, 1),
|
||||||
|
eventRetry: 16,
|
||||||
|
todos: (sessionID) => todos[sessionID] ?? [],
|
||||||
|
})
|
||||||
|
await configurePage(page)
|
||||||
|
|
||||||
|
await page.goto(sessionHref(sourceID))
|
||||||
|
await expectSessionTitle(page, sourceTitle)
|
||||||
|
const dock = page.locator('[data-component="session-todo-dock"]')
|
||||||
|
await expect(dock).toHaveCount(0)
|
||||||
|
|
||||||
|
events.push(statusEvent(sourceID, "busy"))
|
||||||
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
|
|
||||||
|
await page.waitForTimeout(700)
|
||||||
|
const opening = sampleDock(page, 1_000)
|
||||||
|
todos[sourceID] = activeTodos
|
||||||
|
events.push(todoEvent(sourceID, activeTodos))
|
||||||
|
await expect(dock).toBeVisible()
|
||||||
|
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||||
|
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||||
|
|
||||||
|
await switchSession(page, otherID, otherTitle)
|
||||||
|
await expect(dock).toHaveCount(0)
|
||||||
|
|
||||||
|
const returningOpen = sampleDock(page, 700)
|
||||||
|
await switchSession(page, sourceID, sourceTitle)
|
||||||
|
const openSamples = (await returningOpen).filter((sample) => sample.present)
|
||||||
|
expect(openSamples.length).toBeGreaterThan(0)
|
||||||
|
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
|
||||||
|
expect(openSamples[0]!.height).toBeGreaterThan(70)
|
||||||
|
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||||
|
|
||||||
|
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
|
||||||
|
const closing = sampleDock(page, 1_000)
|
||||||
|
todos[sourceID] = completedTodos
|
||||||
|
events.push(todoEvent(sourceID, completedTodos))
|
||||||
|
await expect(dock).toHaveCount(0)
|
||||||
|
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||||
|
todos[sourceID] = []
|
||||||
|
events.push(todoEvent(sourceID, []))
|
||||||
|
|
||||||
|
await switchSession(page, otherID, otherTitle)
|
||||||
|
const returningEmpty = sampleDock(page, 700)
|
||||||
|
await switchSession(page, sourceID, sourceTitle)
|
||||||
|
await expect(dock).toHaveCount(0)
|
||||||
|
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
function session(id: string, title: string, created: number) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created, updated: created },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configurePage(page: Page) {
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, dirBase64, server, sessionIDs }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:tabs",
|
||||||
|
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionHref(sessionID: string) {
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||||
|
const href = sessionHref(sessionID)
|
||||||
|
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleDock(page: Page, duration: number) {
|
||||||
|
return page.evaluate(async (duration) => {
|
||||||
|
const samples: { present: boolean; height: number; opacity: number }[] = []
|
||||||
|
const start = performance.now()
|
||||||
|
while (performance.now() - start < duration) {
|
||||||
|
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
||||||
|
const clip = dock?.parentElement?.parentElement
|
||||||
|
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
||||||
|
samples.push({
|
||||||
|
present: !!dock,
|
||||||
|
height: clip?.getBoundingClientRect().height ?? 0,
|
||||||
|
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
||||||
|
})
|
||||||
|
await new Promise(requestAnimationFrame)
|
||||||
|
}
|
||||||
|
return samples
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
@@ -1,15 +1,6 @@
|
|||||||
import type { Page, Route } from "@playwright/test"
|
import type { Page, Route } from "@playwright/test"
|
||||||
|
|
||||||
const emptyList = new Set([
|
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||||
"/skill",
|
|
||||||
"/command",
|
|
||||||
"/lsp",
|
|
||||||
"/formatter",
|
|
||||||
"/permission",
|
|
||||||
"/question",
|
|
||||||
"/vcs/status",
|
|
||||||
"/vcs/diff",
|
|
||||||
])
|
|
||||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
|
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
|
||||||
|
|
||||||
export interface MockServerConfig {
|
export interface MockServerConfig {
|
||||||
@@ -23,6 +14,9 @@ export interface MockServerConfig {
|
|||||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||||
events?: () => unknown[]
|
events?: () => unknown[]
|
||||||
eventRetry?: number
|
eventRetry?: number
|
||||||
|
todos?: (sessionID: string) => unknown[]
|
||||||
|
permissions?: unknown[] | (() => unknown[])
|
||||||
|
questions?: unknown[] | (() => unknown[])
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
@@ -55,6 +49,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||||
if (path === "/global/health") return json(route, { healthy: true })
|
if (path === "/global/health") return json(route, { healthy: true })
|
||||||
|
if (path === "/permission")
|
||||||
|
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||||
|
if (path === "/question")
|
||||||
|
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||||
if (emptyObject.has(path)) return json(route, {})
|
if (emptyObject.has(path)) return json(route, {})
|
||||||
if (emptyList.has(path)) return json(route, [])
|
if (emptyList.has(path)) return json(route, [])
|
||||||
@@ -66,7 +64,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
return json(route, session ?? {})
|
return json(route, session ?? {})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
|
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||||
|
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||||
|
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||||
|
|
||||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||||
if (messagesMatch) {
|
if (messagesMatch) {
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||||
import { createPromptState } from "@/context/prompt"
|
import { createPromptState } from "@/context/prompt"
|
||||||
|
import { SessionComposerRegion } from "@/pages/session/composer"
|
||||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||||
|
|
||||||
function PromptInputExample() {
|
function createPromptInputStoryRuntime() {
|
||||||
const state = createPromptState()
|
const state = createPromptState()
|
||||||
const history = createPromptInputHistory()
|
return {
|
||||||
|
state,
|
||||||
|
history: createPromptInputHistory(),
|
||||||
|
submission: {
|
||||||
|
abort() {},
|
||||||
|
handleSubmit(event: Event) {
|
||||||
|
event.preventDefault()
|
||||||
|
state.reset()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptInputExample() {
|
||||||
|
const input = createPromptInputStoryRuntime()
|
||||||
const [controls, setControls] = createStore({
|
const [controls, setControls] = createStore({
|
||||||
agent: "build",
|
agent: "build",
|
||||||
variant: undefined as string | undefined,
|
variant: undefined as string | undefined,
|
||||||
@@ -22,13 +38,6 @@ function PromptInputExample() {
|
|||||||
set: (variant?: string) => setControls("variant", variant),
|
set: (variant?: string) => setControls("variant", variant),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const submission = {
|
|
||||||
abort() {},
|
|
||||||
handleSubmit(event: Event) {
|
|
||||||
event.preventDefault()
|
|
||||||
state.reset()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const inputControls = {
|
const inputControls = {
|
||||||
agents: {
|
agents: {
|
||||||
available: [{ name: "review", hidden: false, mode: "subagent" }],
|
available: [{ name: "review", hidden: false, mode: "subagent" }],
|
||||||
@@ -69,7 +78,7 @@ function PromptInputExample() {
|
|||||||
const addReviewComment = () => {
|
const addReviewComment = () => {
|
||||||
const comment = controls.comments + 1
|
const comment = controls.comments + 1
|
||||||
setControls("comments", comment)
|
setControls("comments", comment)
|
||||||
state.context.add({
|
input.state.context.add({
|
||||||
type: "file",
|
type: "file",
|
||||||
path: "src/components/prompt-input.tsx",
|
path: "src/components/prompt-input.tsx",
|
||||||
selection: {
|
selection: {
|
||||||
@@ -87,7 +96,7 @@ function PromptInputExample() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<PromptInput controls={inputControls} state={state} history={history} submission={submission} />
|
<PromptInput controls={inputControls} {...input} />
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -101,6 +110,82 @@ function PromptInputExample() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const todos: Todo[] = [
|
||||||
|
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
|
||||||
|
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
|
||||||
|
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function PromptInputWithOpenDock() {
|
||||||
|
const input = createPromptInputStoryRuntime()
|
||||||
|
const [controls, setControls] = createStore({
|
||||||
|
agent: "build",
|
||||||
|
activeTab: undefined as string | undefined,
|
||||||
|
todoCollapsed: false,
|
||||||
|
})
|
||||||
|
const inputControls = {
|
||||||
|
agents: {
|
||||||
|
available: [],
|
||||||
|
options: ["build"],
|
||||||
|
get current() {
|
||||||
|
return controls.agent
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
visible: true,
|
||||||
|
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
selection: {
|
||||||
|
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||||
|
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||||
|
},
|
||||||
|
paid: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
|
||||||
|
session: {
|
||||||
|
id: "story-session",
|
||||||
|
tabs: {
|
||||||
|
active: () => controls.activeTab,
|
||||||
|
all: () => [],
|
||||||
|
open: () => {},
|
||||||
|
setActive: (tab: string) => setControls("activeTab", tab),
|
||||||
|
},
|
||||||
|
reviewPanel: { opened: () => false, open: () => {} },
|
||||||
|
},
|
||||||
|
newLayoutDesigns: true,
|
||||||
|
}
|
||||||
|
const state = {
|
||||||
|
blocked: () => false,
|
||||||
|
questionRequest: () => undefined,
|
||||||
|
permissionRequest: () => undefined,
|
||||||
|
permissionResponding: () => false,
|
||||||
|
decide: () => {},
|
||||||
|
todos: () => todos,
|
||||||
|
dock: () => true,
|
||||||
|
closing: () => false,
|
||||||
|
opening: () => false,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionComposerRegion
|
||||||
|
state={state}
|
||||||
|
sessionKey="story-session"
|
||||||
|
sessionID="story-session"
|
||||||
|
controls={inputControls}
|
||||||
|
promptInput={{ ...input, ref: () => {}, newSessionWorktree: "", onNewSessionWorktreeReset: () => {} }}
|
||||||
|
todo={{
|
||||||
|
collapsed: controls.todoCollapsed,
|
||||||
|
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||||
|
}}
|
||||||
|
ready
|
||||||
|
centered={false}
|
||||||
|
onResponseSubmit={() => {}}
|
||||||
|
setPromptDockRef={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "App/PromptInput",
|
title: "App/PromptInput",
|
||||||
id: "app-prompt-input",
|
id: "app-prompt-input",
|
||||||
@@ -115,3 +200,12 @@ export const Basic = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DockAlreadyOpen = {
|
||||||
|
render: () => (
|
||||||
|
<div class="pt-10">
|
||||||
|
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
||||||
|
<PromptInputWithOpenDock />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import { useComments } from "@/context/comments"
|
|||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import {
|
||||||
|
createSessionComposerControls,
|
||||||
|
createSessionComposerState,
|
||||||
|
SessionComposerRegion,
|
||||||
|
} from "@/pages/session/composer"
|
||||||
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||||
@@ -17,12 +23,19 @@ export default function NewSessionPage() {
|
|||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const serverSync = useServerSync()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
|
const route = useSessionKey()
|
||||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||||
|
|
||||||
let inputRef: HTMLDivElement | undefined
|
let inputRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
const composer = createSessionComposerState()
|
const composer = createSessionComposerState()
|
||||||
|
const composerControls = createSessionComposerControls({
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
sessionID: () => route.params.id,
|
||||||
|
queryOptions: serverSync().queryOptions,
|
||||||
|
})
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
worktree: "main",
|
worktree: "main",
|
||||||
@@ -57,15 +70,21 @@ export default function NewSessionPage() {
|
|||||||
<NewSessionDesignView>
|
<NewSessionDesignView>
|
||||||
<SessionComposerRegion
|
<SessionComposerRegion
|
||||||
state={composer}
|
state={composer}
|
||||||
|
sessionKey={route.sessionKey()}
|
||||||
|
sessionID={route.params.id}
|
||||||
|
controls={composerControls()}
|
||||||
|
promptInput={{
|
||||||
|
ref: (el) => {
|
||||||
|
inputRef = el
|
||||||
|
},
|
||||||
|
newSessionWorktree: newSessionWorktree(),
|
||||||
|
onNewSessionWorktreeReset: () => setStore("worktree", "main"),
|
||||||
|
onSubmit: () => comments.clear(),
|
||||||
|
}}
|
||||||
|
todo={{ collapsed: false, onToggle: () => {} }}
|
||||||
ready
|
ready
|
||||||
centered={false}
|
centered={false}
|
||||||
placement="inline"
|
placement="inline"
|
||||||
inputRef={(el) => {
|
|
||||||
inputRef = el
|
|
||||||
}}
|
|
||||||
newSessionWorktree={newSessionWorktree()}
|
|
||||||
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
|
|
||||||
onSubmit={() => comments.clear()}
|
|
||||||
onResponseSubmit={() => {}}
|
onResponseSubmit={() => {}}
|
||||||
setPromptDockRef={() => {}}
|
setPromptDockRef={() => {}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ import { useSettings } from "@/context/settings"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
import {
|
||||||
|
createSessionComposerControls,
|
||||||
|
createSessionComposerState,
|
||||||
|
SessionComposerRegion,
|
||||||
|
} from "@/pages/session/composer"
|
||||||
import {
|
import {
|
||||||
createOpenReviewFile,
|
createOpenReviewFile,
|
||||||
createSessionTabs,
|
createSessionTabs,
|
||||||
@@ -152,6 +156,11 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const composer = createSessionComposerState()
|
const composer = createSessionComposerState()
|
||||||
|
const composerControls = createSessionComposerControls({
|
||||||
|
sessionKey,
|
||||||
|
sessionID: () => params.id,
|
||||||
|
queryOptions: serverSync().queryOptions,
|
||||||
|
})
|
||||||
|
|
||||||
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
|
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
|
||||||
|
|
||||||
@@ -1561,6 +1570,24 @@ export default function Page() {
|
|||||||
const composerRegion = (placement: "dock" | "inline") => (
|
const composerRegion = (placement: "dock" | "inline") => (
|
||||||
<SessionComposerRegion
|
<SessionComposerRegion
|
||||||
state={composer}
|
state={composer}
|
||||||
|
sessionKey={sessionKey()}
|
||||||
|
sessionID={params.id}
|
||||||
|
controls={composerControls()}
|
||||||
|
promptInput={{
|
||||||
|
ref: (el) => {
|
||||||
|
inputRef = el
|
||||||
|
},
|
||||||
|
newSessionWorktree: newSessionWorktree(),
|
||||||
|
onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"),
|
||||||
|
onSubmit: () => {
|
||||||
|
comments.clear()
|
||||||
|
resumeScroll()
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
todo={{
|
||||||
|
collapsed: view().todoCollapsed.get(),
|
||||||
|
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
|
||||||
|
}}
|
||||||
ready={!store.deferRender && messagesReady()}
|
ready={!store.deferRender && messagesReady()}
|
||||||
centered={placement === "dock" && centered()}
|
centered={placement === "dock" && centered()}
|
||||||
placement={placement}
|
placement={placement}
|
||||||
@@ -1573,15 +1600,6 @@ export default function Page() {
|
|||||||
: legacySessionHref(sdk().directory, id),
|
: legacySessionHref(sdk().directory, id),
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
inputRef={(el) => {
|
|
||||||
inputRef = el
|
|
||||||
}}
|
|
||||||
newSessionWorktree={newSessionWorktree()}
|
|
||||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
|
||||||
onSubmit={() => {
|
|
||||||
comments.clear()
|
|
||||||
resumeScroll()
|
|
||||||
}}
|
|
||||||
onResponseSubmit={resumeScroll}
|
onResponseSubmit={resumeScroll}
|
||||||
followup={
|
followup={
|
||||||
params.id && !isChildSession()
|
params.id && !isChildSession()
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { SessionComposerRegion } from "./session-composer-region"
|
export { SessionComposerRegion } from "./session-composer-region"
|
||||||
|
export { createSessionComposerControls } from "./session-composer-controls"
|
||||||
export { createSessionComposerState } from "./session-composer-state"
|
export { createSessionComposerState } from "./session-composer-state"
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { createQuery } from "@tanstack/solid-query"
|
||||||
|
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||||
|
import { type Accessor, createMemo } from "solid-js"
|
||||||
|
import type { PromptInputControls } from "@/components/prompt-input"
|
||||||
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import type { QueryOptionsApi } from "@/context/server-sync"
|
||||||
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { type DraftTab, useTabs } from "@/context/tabs"
|
||||||
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
|
export function createSessionComposerControls(input: {
|
||||||
|
sessionKey: Accessor<string>
|
||||||
|
sessionID: Accessor<string | undefined>
|
||||||
|
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const layout = useLayout()
|
||||||
|
const local = useLocal()
|
||||||
|
const providers = useProviders()
|
||||||
|
const settings = useSettings()
|
||||||
|
const server = useServer()
|
||||||
|
const sync = useSync()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const global = useGlobal()
|
||||||
|
const pickDirectory = useDirectoryPicker()
|
||||||
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
|
const view = layout.view(input.sessionKey)
|
||||||
|
|
||||||
|
const draft = createMemo(() => {
|
||||||
|
if (!search.draftId) return
|
||||||
|
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||||
|
})
|
||||||
|
const projectServer = createMemo(() => {
|
||||||
|
if (!search.draftId) return server.current
|
||||||
|
const target = draft()?.server
|
||||||
|
if (!target) return
|
||||||
|
return server.list.find((conn) => ServerConnection.key(conn) === target)
|
||||||
|
})
|
||||||
|
const projectServerCtx = createMemo(() => {
|
||||||
|
const conn = projectServer()
|
||||||
|
if (conn) return global.ensureServerCtx(conn)
|
||||||
|
})
|
||||||
|
const projects = createMemo(() =>
|
||||||
|
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
|
||||||
|
)
|
||||||
|
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
|
||||||
|
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
|
||||||
|
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
|
||||||
|
|
||||||
|
const selectProject = (worktree: string) => {
|
||||||
|
const conn = projectServer()
|
||||||
|
const target = projectServerCtx()
|
||||||
|
if (search.draftId) {
|
||||||
|
if (!conn || !target) return
|
||||||
|
target.projects.open(worktree)
|
||||||
|
target.projects.touch(worktree)
|
||||||
|
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
layout.projects.open(worktree)
|
||||||
|
server.projects.touch(worktree)
|
||||||
|
navigate(`/${base64Encode(worktree)}/session`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addProject = (title: string) => {
|
||||||
|
const conn = projectServer()
|
||||||
|
if (!conn) return
|
||||||
|
pickDirectory({
|
||||||
|
server: conn,
|
||||||
|
title,
|
||||||
|
onSelect: (result) => {
|
||||||
|
const directory = Array.isArray(result) ? result[0] : result
|
||||||
|
if (directory) selectProject(directory)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return createMemo<PromptInputControls>(() => ({
|
||||||
|
agents: {
|
||||||
|
available: sync().data.agent,
|
||||||
|
options: local.agent.list().map((agent) => agent.name),
|
||||||
|
current: local.agent.current()?.name ?? "",
|
||||||
|
loading: agentsQuery.isLoading,
|
||||||
|
visible: settings.visibility.customAgents(),
|
||||||
|
select: local.agent.set,
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
selection: local.model,
|
||||||
|
paid: providers.paid().length > 0,
|
||||||
|
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
available: projects(),
|
||||||
|
directory: sdk().directory,
|
||||||
|
select: selectProject,
|
||||||
|
add: addProject,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
id: input.sessionID(),
|
||||||
|
tabs: layout.tabs(input.sessionKey),
|
||||||
|
reviewPanel: view.reviewPanel,
|
||||||
|
},
|
||||||
|
newLayoutDesigns: settings.general.newLayoutDesigns(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
|
||||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
import { useLayout } from "@/context/layout"
|
import { PromptInput, type PromptInputControls, type PromptInputProps } from "@/components/prompt-input"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
|
||||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||||
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
|
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
|
||||||
@@ -18,29 +15,21 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
|||||||
import type { FollowupDraft } from "@/components/prompt-input/submit"
|
import type { FollowupDraft } from "@/components/prompt-input/submit"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||||
import { createQuery } from "@tanstack/solid-query"
|
|
||||||
import { useQueryOptions } from "@/context/server-sync"
|
|
||||||
import { useSDK } from "@/context/sdk"
|
|
||||||
import { pathKey } from "@/utils/path-key"
|
|
||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
|
||||||
import { type DraftTab, useTabs } from "@/context/tabs"
|
|
||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
|
|
||||||
export function SessionComposerRegion(props: {
|
export function SessionComposerRegion(props: {
|
||||||
state: SessionComposerState
|
state: SessionComposerState
|
||||||
|
sessionKey: string
|
||||||
|
sessionID?: string
|
||||||
|
controls: PromptInputControls
|
||||||
|
promptInput: Omit<PromptInputProps, "controls" | "variant">
|
||||||
|
todo: {
|
||||||
|
collapsed: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}
|
||||||
ready: boolean
|
ready: boolean
|
||||||
centered: boolean
|
centered: boolean
|
||||||
placement?: "dock" | "inline"
|
placement?: "dock" | "inline"
|
||||||
openParent?: () => void
|
openParent?: () => void
|
||||||
inputRef: (el: HTMLDivElement) => void
|
|
||||||
newSessionWorktree: string
|
|
||||||
onNewSessionWorktreeReset: () => void
|
|
||||||
onSubmit: () => void
|
|
||||||
onResponseSubmit: () => void
|
onResponseSubmit: () => void
|
||||||
followup?: {
|
followup?: {
|
||||||
queue: () => boolean
|
queue: () => boolean
|
||||||
@@ -61,102 +50,12 @@ export function SessionComposerRegion(props: {
|
|||||||
}
|
}
|
||||||
setPromptDockRef: (el: HTMLDivElement) => void
|
setPromptDockRef: (el: HTMLDivElement) => void
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const prompt = props.promptInput.state ?? usePrompt()
|
||||||
const layout = useLayout()
|
|
||||||
const prompt = usePrompt()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const route = useSessionKey()
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
|
||||||
const queryOptions = useQueryOptions()
|
|
||||||
const local = useLocal()
|
|
||||||
const providers = useProviders(() => sdk().directory)
|
|
||||||
const settings = useSettings()
|
|
||||||
const server = useServer()
|
|
||||||
const tabs = useTabs()
|
|
||||||
const global = useGlobal()
|
|
||||||
const pickDirectory = useDirectoryPicker()
|
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
|
||||||
const view = layout.view(route.sessionKey)
|
|
||||||
|
|
||||||
const draft = createMemo(() => {
|
const handoffPrompt = createMemo(() => getSessionHandoff(props.sessionKey)?.prompt)
|
||||||
if (!search.draftId) return
|
const info = createMemo(() => (props.sessionID ? sync().session.get(props.sessionID) : undefined))
|
||||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
|
||||||
})
|
|
||||||
const projectServer = createMemo(() => {
|
|
||||||
if (!search.draftId) return server.current
|
|
||||||
const target = draft()?.server
|
|
||||||
if (!target) return
|
|
||||||
return server.list.find((conn) => ServerConnection.key(conn) === target)
|
|
||||||
})
|
|
||||||
const projectServerCtx = createMemo(() => {
|
|
||||||
const conn = projectServer()
|
|
||||||
if (conn) return global.ensureServerCtx(conn)
|
|
||||||
})
|
|
||||||
const projects = createMemo(() =>
|
|
||||||
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
|
|
||||||
)
|
|
||||||
|
|
||||||
const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
|
|
||||||
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
|
|
||||||
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
|
|
||||||
const selectProject = (worktree: string) => {
|
|
||||||
const conn = projectServer()
|
|
||||||
const target = projectServerCtx()
|
|
||||||
if (search.draftId) {
|
|
||||||
if (!conn || !target) return
|
|
||||||
target.projects.open(worktree)
|
|
||||||
target.projects.touch(worktree)
|
|
||||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
layout.projects.open(worktree)
|
|
||||||
server.projects.touch(worktree)
|
|
||||||
navigate(`/${base64Encode(worktree)}/session`)
|
|
||||||
}
|
|
||||||
const addProject = (title: string) => {
|
|
||||||
const conn = projectServer()
|
|
||||||
if (!conn) return
|
|
||||||
pickDirectory({
|
|
||||||
server: conn,
|
|
||||||
title,
|
|
||||||
onSelect: (result) => {
|
|
||||||
const directory = Array.isArray(result) ? result[0] : result
|
|
||||||
if (directory) selectProject(directory)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const controls = createMemo(() => ({
|
|
||||||
agents: {
|
|
||||||
available: sync().data.agent,
|
|
||||||
options: local.agent.list().map((agent) => agent.name),
|
|
||||||
current: local.agent.current()?.name ?? "",
|
|
||||||
loading: agentsQuery.isLoading,
|
|
||||||
visible: settings.visibility.customAgents(),
|
|
||||||
select: local.agent.set,
|
|
||||||
},
|
|
||||||
model: {
|
|
||||||
selection: local.model,
|
|
||||||
paid: providers.paid().length > 0,
|
|
||||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
|
||||||
},
|
|
||||||
projects: {
|
|
||||||
available: projects(),
|
|
||||||
directory: sdk().directory,
|
|
||||||
select: selectProject,
|
|
||||||
add: addProject,
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
id: route.params.id,
|
|
||||||
tabs: layout.tabs(route.sessionKey),
|
|
||||||
reviewPanel: view.reviewPanel,
|
|
||||||
},
|
|
||||||
newLayoutDesigns: settings.general.newLayoutDesigns(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
|
||||||
const info = createMemo(() => (route.params.id ? sync().session.get(route.params.id) : undefined))
|
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
const child = createMemo(() => !!parentID())
|
const child = createMemo(() => !!parentID())
|
||||||
const showComposer = createMemo(() => !props.state.blocked() || child())
|
const showComposer = createMemo(() => !props.state.blocked() || child())
|
||||||
@@ -175,11 +74,11 @@ export function SessionComposerRegion(props: {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!prompt.ready()) return
|
if (!prompt.ready()) return
|
||||||
setSessionHandoff(route.sessionKey(), { prompt: previewPrompt() })
|
setSessionHandoff(props.sessionKey, { prompt: previewPrompt() })
|
||||||
})
|
})
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
ready: false,
|
ready: props.ready || props.state.dock(),
|
||||||
height: 320,
|
height: 320,
|
||||||
body: undefined as HTMLDivElement | undefined,
|
body: undefined as HTMLDivElement | undefined,
|
||||||
})
|
})
|
||||||
@@ -198,13 +97,17 @@ export function SessionComposerRegion(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
route.sessionKey()
|
props.sessionKey
|
||||||
const ready = props.ready
|
const ready = props.ready
|
||||||
|
const dock = props.state.dock()
|
||||||
const delay = 140
|
const delay = 140
|
||||||
|
|
||||||
clear()
|
clear()
|
||||||
setStore("ready", false)
|
if (store.ready || (!ready && !dock)) return
|
||||||
if (!ready) return
|
if (dock) {
|
||||||
|
setStore("ready", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
frame = requestAnimationFrame(() => {
|
frame = requestAnimationFrame(() => {
|
||||||
frame = undefined
|
frame = undefined
|
||||||
@@ -218,7 +121,11 @@ export function SessionComposerRegion(props: {
|
|||||||
onCleanup(clear)
|
onCleanup(clear)
|
||||||
|
|
||||||
const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing())
|
const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing())
|
||||||
const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
const progress = useSpring(
|
||||||
|
() => (open() ? 1 : 0),
|
||||||
|
{ visualDuration: 0.3, bounce: 0 },
|
||||||
|
() => `${props.sessionKey}\0${store.ready}`,
|
||||||
|
)
|
||||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||||
const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001)
|
const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001)
|
||||||
const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
|
const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
|
||||||
@@ -280,8 +187,30 @@ export function SessionComposerRegion(props: {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={showComposer()}>
|
<Show when={showComposer()}>
|
||||||
|
<Show when={dock()}>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"overflow-hidden": true,
|
||||||
|
"pointer-events-none": value() < 0.98,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
"max-height": `${full() * value()}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={(el) => setStore("body", el)}>
|
||||||
|
<SessionTodoDock
|
||||||
|
todos={props.state.todos()}
|
||||||
|
collapsed={props.todo.collapsed}
|
||||||
|
onToggle={props.todo.onToggle}
|
||||||
|
collapseLabel={language.t("session.todo.collapse")}
|
||||||
|
expandLabel={language.t("session.todo.expand")}
|
||||||
|
dockProgress={value()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={promptReadyResource()}
|
when={prompt.ready() || promptReadyResource()}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<Show when={rolled()} keyed>
|
<Show when={rolled()} keyed>
|
||||||
@@ -296,35 +225,15 @@ export function SessionComposerRegion(props: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
<div
|
||||||
|
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
|
||||||
|
style={{ "margin-top": `${-36 * value()}px` }}
|
||||||
|
>
|
||||||
{handoffPrompt() || language.t("prompt.loading")}
|
{handoffPrompt() || language.t("prompt.loading")}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show when={dock()}>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"overflow-hidden": true,
|
|
||||||
"pointer-events-none": value() < 0.98,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
"max-height": `${full() * value()}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div ref={(el) => setStore("body", el)}>
|
|
||||||
<SessionTodoDock
|
|
||||||
sessionID={route.params.id}
|
|
||||||
todos={props.state.todos()}
|
|
||||||
collapsed={view.todoCollapsed.get()}
|
|
||||||
onToggle={() => view.todoCollapsed.set(!view.todoCollapsed.get())}
|
|
||||||
collapseLabel={language.t("session.todo.collapse")}
|
|
||||||
expandLabel={language.t("session.todo.expand")}
|
|
||||||
dockProgress={value()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={rolled()} keyed>
|
<Show when={rolled()} keyed>
|
||||||
{(revert) => (
|
{(revert) => (
|
||||||
<div
|
<div
|
||||||
@@ -362,23 +271,20 @@ export function SessionComposerRegion(props: {
|
|||||||
fallback={
|
fallback={
|
||||||
<Show when={!props.state.blocked()}>
|
<Show when={!props.state.blocked()}>
|
||||||
<PromptInput
|
<PromptInput
|
||||||
controls={controls()}
|
{...props.promptInput}
|
||||||
|
controls={props.controls}
|
||||||
variant={props.placement === "inline" ? "new-session" : undefined}
|
variant={props.placement === "inline" ? "new-session" : undefined}
|
||||||
ref={props.inputRef}
|
|
||||||
newSessionWorktree={props.newSessionWorktree}
|
|
||||||
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
|
||||||
edit={props.followup?.edit}
|
edit={props.followup?.edit}
|
||||||
onEditLoaded={props.followup?.onEditLoaded}
|
onEditLoaded={props.followup?.onEditLoaded}
|
||||||
shouldQueue={props.followup?.queue}
|
shouldQueue={props.followup?.queue}
|
||||||
onQueue={props.followup?.onQueue}
|
onQueue={props.followup?.onQueue}
|
||||||
onAbort={props.followup?.onAbort}
|
onAbort={props.followup?.onAbort}
|
||||||
onSubmit={props.onSubmit}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={props.inputRef}
|
ref={props.promptInput.ref}
|
||||||
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
|
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
|
||||||
>
|
>
|
||||||
<span>{language.t("session.child.promptDisabled")} </span>
|
<span>{language.t("session.child.promptDisabled")} </span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { todoState } from "./session-composer-state"
|
import { todoDockAtBoundary, todoState } from "./session-composer-state"
|
||||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||||
|
|
||||||
const session = (input: { id: string; parentID?: string }) =>
|
const session = (input: { id: string; parentID?: string }) =>
|
||||||
@@ -126,3 +126,13 @@ describe("todoState", () => {
|
|||||||
expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
|
expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("todoDockAtBoundary", () => {
|
||||||
|
test("shows active todos when entering a session", () => {
|
||||||
|
expect(todoDockAtBoundary("open")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("hides completed todos when entering a session", () => {
|
||||||
|
expect(todoDockAtBoundary("close")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const todoState = (input: {
|
|||||||
return "close"
|
return "close"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state === "open"
|
||||||
|
|
||||||
const idle = { type: "idle" as const }
|
const idle = { type: "idle" as const }
|
||||||
|
|
||||||
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
|
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
|
||||||
@@ -60,8 +62,9 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||||||
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
|
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
|
sessionID: params.id,
|
||||||
responding: undefined as string | undefined,
|
responding: undefined as string | undefined,
|
||||||
dock: todos().length > 0 && live(),
|
dock: todos().length > 0 && !done() && live(),
|
||||||
closing: false,
|
closing: false,
|
||||||
opening: false,
|
opening: false,
|
||||||
})
|
})
|
||||||
@@ -116,8 +119,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => [todos().length, done(), live()] as const,
|
() => [params.id, todos().length, done(), live()] as const,
|
||||||
([count, complete, active]) => {
|
([id, count, complete, active], previous) => {
|
||||||
if (raf) cancelAnimationFrame(raf)
|
if (raf) cancelAnimationFrame(raf)
|
||||||
raf = undefined
|
raf = undefined
|
||||||
|
|
||||||
@@ -127,6 +130,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||||||
live: active,
|
live: active,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!previous || previous[0] !== id) {
|
||||||
|
if (timer) window.clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false })
|
||||||
|
if (next === "clear") clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (next === "hide") {
|
if (next === "hide") {
|
||||||
if (timer) window.clearTimeout(timer)
|
if (timer) window.clearTimeout(timer)
|
||||||
timer = undefined
|
timer = undefined
|
||||||
@@ -181,9 +192,12 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||||||
permissionResponding,
|
permissionResponding,
|
||||||
decide,
|
decide,
|
||||||
todos,
|
todos,
|
||||||
dock: () => store.dock,
|
dock: () =>
|
||||||
closing: () => store.closing,
|
store.sessionID === params.id
|
||||||
opening: () => store.opening,
|
? store.dock
|
||||||
|
: todoDockAtBoundary(todoState({ count: todos().length, done: done(), live: live() })),
|
||||||
|
closing: () => store.sessionID === params.id && store.closing,
|
||||||
|
opening: () => store.sessionID === params.id && store.opening,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ function dot(status: Todo["status"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SessionTodoDock(props: {
|
export function SessionTodoDock(props: {
|
||||||
sessionID?: string
|
|
||||||
todos: Todo[]
|
todos: Todo[]
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
@@ -50,7 +49,7 @@ export function SessionTodoDock(props: {
|
|||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
height: 320,
|
height: 78,
|
||||||
})
|
})
|
||||||
|
|
||||||
const total = createMemo(() => props.todos.length)
|
const total = createMemo(() => props.todos.length)
|
||||||
@@ -85,7 +84,7 @@ export function SessionTodoDock(props: {
|
|||||||
const el = contentRef
|
const el = contentRef
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const update = () => {
|
const update = () => {
|
||||||
setStore("height", el.getBoundingClientRect().height)
|
setStore("height", (height) => Math.max(height, el.scrollHeight))
|
||||||
}
|
}
|
||||||
update()
|
update()
|
||||||
createResizeObserver(el, update)
|
createResizeObserver(el, update)
|
||||||
|
|||||||
@@ -50,6 +50,25 @@ const btn = (accent?: boolean) =>
|
|||||||
"font-size": "13px",
|
"font-size": "13px",
|
||||||
}) as const
|
}) as const
|
||||||
|
|
||||||
|
const controls = {
|
||||||
|
agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} },
|
||||||
|
model: {
|
||||||
|
selection: {
|
||||||
|
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||||
|
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||||
|
},
|
||||||
|
paid: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
|
||||||
|
session: {
|
||||||
|
id: "story-session",
|
||||||
|
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
|
||||||
|
reviewPanel: { opened: () => false, open: () => {} },
|
||||||
|
},
|
||||||
|
newLayoutDesigns: true,
|
||||||
|
}
|
||||||
|
|
||||||
const css = `
|
const css = `
|
||||||
[data-component="todo-stage"] {
|
[data-component="todo-stage"] {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -132,6 +151,7 @@ export const Playground = {
|
|||||||
const global = useServerSync()
|
const global = useServerSync()
|
||||||
const [cfg, setCfg] = createStore({
|
const [cfg, setCfg] = createStore({
|
||||||
open: true,
|
open: true,
|
||||||
|
collapsed: false,
|
||||||
step: 1,
|
step: 1,
|
||||||
dockOpenDuration: 0.3,
|
dockOpenDuration: 0.3,
|
||||||
dockOpenBounce: 0,
|
dockOpenBounce: 0,
|
||||||
@@ -170,7 +190,6 @@ export const Playground = {
|
|||||||
const countWidthDuration = () => cfg.countWidthDuration
|
const countWidthDuration = () => cfg.countWidthDuration
|
||||||
const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
|
const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
|
||||||
let frame
|
let frame
|
||||||
let composerRef
|
|
||||||
let scrollRef
|
let scrollRef
|
||||||
|
|
||||||
const todos = createMemo<Todo[]>(() => {
|
const todos = createMemo<Todo[]>(() => {
|
||||||
@@ -196,15 +215,8 @@ export const Playground = {
|
|||||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
const collapsed = () =>
|
const collapsed = () => cfg.collapsed
|
||||||
!!composerRef?.querySelector('[data-action="session-todo-toggle-button"][data-collapsed="true"]')
|
const setCollapsed = (value: boolean) => setCfg("collapsed", value)
|
||||||
|
|
||||||
const setCollapsed = (value: boolean) => {
|
|
||||||
const button = composerRef?.querySelector('[data-action="session-todo-toggle-button"]')
|
|
||||||
if (!(button instanceof HTMLButtonElement)) return
|
|
||||||
if (collapsed() === value) return
|
|
||||||
button.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const openDock = () => {
|
const openDock = () => {
|
||||||
clear()
|
clear()
|
||||||
@@ -267,14 +279,21 @@ export const Playground = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={composerRef}>
|
<div>
|
||||||
<SessionComposerRegion
|
<SessionComposerRegion
|
||||||
state={state}
|
state={state}
|
||||||
|
sessionKey="story-session"
|
||||||
|
sessionID="story-session"
|
||||||
|
controls={controls}
|
||||||
|
promptInput={{
|
||||||
|
submission: { abort: () => {}, handleSubmit: (event) => event.preventDefault() },
|
||||||
|
ref: () => {},
|
||||||
|
newSessionWorktree: "",
|
||||||
|
onNewSessionWorktreeReset: () => {},
|
||||||
|
}}
|
||||||
|
todo={{ collapsed: collapsed(), onToggle: () => setCollapsed(!collapsed()) }}
|
||||||
|
ready
|
||||||
centered={false}
|
centered={false}
|
||||||
inputRef={() => {}}
|
|
||||||
newSessionWorktree=""
|
|
||||||
onNewSessionWorktreeReset={() => {}}
|
|
||||||
onSubmit={() => {}}
|
|
||||||
onResponseSubmit={pin}
|
onResponseSubmit={pin}
|
||||||
setPromptDockRef={() => {}}
|
setPromptDockRef={() => {}}
|
||||||
dockOpenVisualDuration={dockOpenDuration()}
|
dockOpenVisualDuration={dockOpenDuration()}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
|
import { createRoot, createSignal } from "solid-js"
|
||||||
|
|
||||||
|
test("snaps spring progress when the session changes", async () => {
|
||||||
|
const state = createRoot((dispose) => {
|
||||||
|
const [target, setTarget] = createSignal(0)
|
||||||
|
const [session, setSession] = createSignal("session-a")
|
||||||
|
const progress = useSpring(target, { visualDuration: 0.3, bounce: 0 }, session)
|
||||||
|
return { dispose, progress, setTarget, setSession }
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise<void>(queueMicrotask)
|
||||||
|
state.setTarget(1)
|
||||||
|
expect(state.progress()).toBe(0)
|
||||||
|
|
||||||
|
state.setSession("session-b")
|
||||||
|
expect(state.progress()).toBe(1)
|
||||||
|
state.dispose()
|
||||||
|
})
|
||||||
@@ -73,8 +73,8 @@ export function createPromptState() {
|
|||||||
key: item.key ?? `ctx:${++index}`,
|
key: item.key ?? `ctx:${++index}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
const value = {
|
||||||
ready: () => ready,
|
ready,
|
||||||
current: () => store.prompt,
|
current: () => store.prompt,
|
||||||
cursor: () => store.cursor,
|
cursor: () => store.cursor,
|
||||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||||
@@ -87,6 +87,7 @@ export function createPromptState() {
|
|||||||
setStore("cursor", 0)
|
setStore("cursor", 0)
|
||||||
setStore("items", (current) => current.filter((item) => !!item.comment?.trim()))
|
setStore("items", (current) => current.filter((item) => !!item.comment?.trim()))
|
||||||
},
|
},
|
||||||
|
capture: () => value,
|
||||||
context: {
|
context: {
|
||||||
items: () => store.items,
|
items: () => store.items,
|
||||||
add(item: Omit<ContextItem, "key"> & { key?: string }) {
|
add(item: Omit<ContextItem, "key"> & { key?: string }) {
|
||||||
@@ -116,6 +117,7 @@ export function createPromptState() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = createPromptState()
|
const prompt = createPromptState()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 { createComputed, createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
|
|
||||||
type Opt = Partial<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) =>
|
||||||
@@ -11,17 +11,30 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
|
|||||||
a?.mass === b?.mass &&
|
a?.mass === b?.mass &&
|
||||||
a?.velocity === b?.velocity
|
a?.velocity === b?.velocity
|
||||||
|
|
||||||
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
|
export function useSpring(target: () => number, options?: Opt | (() => Opt), snapKey?: () => unknown) {
|
||||||
const read = () => (typeof options === "function" ? options() : options)
|
const read = () => (typeof options === "function" ? options() : options)
|
||||||
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 snapValue = snapKey?.()
|
||||||
let stop = attachSpring(spring, source, config)
|
let stop = attachSpring(spring, source, config)
|
||||||
let off = spring.on("change", (next: number) => setValue(next))
|
let off = spring.on("change", (next: number) => setValue(next))
|
||||||
|
|
||||||
createEffect(() => {
|
createComputed(() => {
|
||||||
source.set(target())
|
const next = target()
|
||||||
|
const nextSnap = snapKey?.()
|
||||||
|
if (snapKey && nextSnap !== snapValue) {
|
||||||
|
// State boundaries should adopt their target without animating from the previous context.
|
||||||
|
snapValue = nextSnap
|
||||||
|
stop()
|
||||||
|
spring.jump(next)
|
||||||
|
source.jump(next)
|
||||||
|
stop = attachSpring(spring, source, config)
|
||||||
|
setValue(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source.set(next)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user