fix(app): preserve todo dock across sessions (#33778)
This commit is contained in:
parent
bdee94ca40
commit
d13779b1d5
16 changed files with 765 additions and 219 deletions
|
|
@ -1,11 +1,27 @@
|
|||
// @ts-nocheck
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||
|
||||
function PromptInputExample() {
|
||||
function createPromptInputStoryRuntime() {
|
||||
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({
|
||||
agent: "build",
|
||||
variant: undefined as string | undefined,
|
||||
|
|
@ -22,13 +38,6 @@ function PromptInputExample() {
|
|||
set: (variant?: string) => setControls("variant", variant),
|
||||
},
|
||||
}
|
||||
const submission = {
|
||||
abort() {},
|
||||
handleSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
state.reset()
|
||||
},
|
||||
}
|
||||
const inputControls = {
|
||||
agents: {
|
||||
available: [{ name: "review", hidden: false, mode: "subagent" }],
|
||||
|
|
@ -69,7 +78,7 @@ function PromptInputExample() {
|
|||
const addReviewComment = () => {
|
||||
const comment = controls.comments + 1
|
||||
setControls("comments", comment)
|
||||
state.context.add({
|
||||
input.state.context.add({
|
||||
type: "file",
|
||||
path: "src/components/prompt-input.tsx",
|
||||
selection: {
|
||||
|
|
@ -87,7 +96,7 @@ function PromptInputExample() {
|
|||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInput controls={inputControls} state={state} history={history} submission={submission} />
|
||||
<PromptInput controls={inputControls} {...input} />
|
||||
<div>
|
||||
<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 {
|
||||
title: "App/PromptInput",
|
||||
id: "app-prompt-input",
|
||||
|
|
@ -115,3 +200,12 @@ export const Basic = {
|
|||
</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 { useSDK } from "@/context/sdk"
|
||||
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
|
||||
|
|
@ -17,12 +23,19 @@ export default function NewSessionPage() {
|
|||
const prompt = usePrompt()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const comments = useComments()
|
||||
const route = useSessionKey()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
|
||||
let inputRef: HTMLDivElement | undefined
|
||||
|
||||
const composer = createSessionComposerState()
|
||||
const composerControls = createSessionComposerControls({
|
||||
sessionKey: route.sessionKey,
|
||||
sessionID: () => route.params.id,
|
||||
queryOptions: serverSync().queryOptions,
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
worktree: "main",
|
||||
|
|
@ -57,15 +70,21 @@ export default function NewSessionPage() {
|
|||
<NewSessionDesignView>
|
||||
<SessionComposerRegion
|
||||
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
|
||||
centered={false}
|
||||
placement="inline"
|
||||
inputRef={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
|
||||
onSubmit={() => comments.clear()}
|
||||
onResponseSubmit={() => {}}
|
||||
setPromptDockRef={() => {}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ import { useSettings } from "@/context/settings"
|
|||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
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 {
|
||||
createOpenReviewFile,
|
||||
createSessionTabs,
|
||||
|
|
@ -152,6 +156,11 @@ export default function Page() {
|
|||
})
|
||||
|
||||
const composer = createSessionComposerState()
|
||||
const composerControls = createSessionComposerControls({
|
||||
sessionKey,
|
||||
sessionID: () => params.id,
|
||||
queryOptions: serverSync().queryOptions,
|
||||
})
|
||||
|
||||
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
|
||||
|
||||
|
|
@ -1561,6 +1570,24 @@ export default function Page() {
|
|||
const composerRegion = (placement: "dock" | "inline") => (
|
||||
<SessionComposerRegion
|
||||
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()}
|
||||
centered={placement === "dock" && centered()}
|
||||
placement={placement}
|
||||
|
|
@ -1573,15 +1600,6 @@ export default function Page() {
|
|||
: legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
}}
|
||||
inputRef={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||
onSubmit={() => {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
}}
|
||||
onResponseSubmit={resumeScroll}
|
||||
followup={
|
||||
params.id && !isChildSession()
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export { SessionComposerRegion } from "./session-composer-region"
|
||||
export { createSessionComposerControls } from "./session-composer-controls"
|
||||
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 { createStore } from "solid-js/store"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { PromptInput, type PromptInputControls, type PromptInputProps } from "@/components/prompt-input"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-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 { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
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: {
|
||||
state: SessionComposerState
|
||||
sessionKey: string
|
||||
sessionID?: string
|
||||
controls: PromptInputControls
|
||||
promptInput: Omit<PromptInputProps, "controls" | "variant">
|
||||
todo: {
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
ready: boolean
|
||||
centered: boolean
|
||||
placement?: "dock" | "inline"
|
||||
openParent?: () => void
|
||||
inputRef: (el: HTMLDivElement) => void
|
||||
newSessionWorktree: string
|
||||
onNewSessionWorktreeReset: () => void
|
||||
onSubmit: () => void
|
||||
onResponseSubmit: () => void
|
||||
followup?: {
|
||||
queue: () => boolean
|
||||
|
|
@ -61,102 +50,12 @@ export function SessionComposerRegion(props: {
|
|||
}
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
const prompt = usePrompt()
|
||||
const prompt = props.promptInput.state ?? usePrompt()
|
||||
const language = useLanguage()
|
||||
const route = useSessionKey()
|
||||
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(() => {
|
||||
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(() => 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 handoffPrompt = createMemo(() => getSessionHandoff(props.sessionKey)?.prompt)
|
||||
const info = createMemo(() => (props.sessionID ? sync().session.get(props.sessionID) : undefined))
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const child = createMemo(() => !!parentID())
|
||||
const showComposer = createMemo(() => !props.state.blocked() || child())
|
||||
|
|
@ -175,11 +74,11 @@ export function SessionComposerRegion(props: {
|
|||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
setSessionHandoff(route.sessionKey(), { prompt: previewPrompt() })
|
||||
setSessionHandoff(props.sessionKey, { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
ready: props.ready || props.state.dock(),
|
||||
height: 320,
|
||||
body: undefined as HTMLDivElement | undefined,
|
||||
})
|
||||
|
|
@ -198,13 +97,17 @@ export function SessionComposerRegion(props: {
|
|||
}
|
||||
|
||||
createEffect(() => {
|
||||
route.sessionKey()
|
||||
props.sessionKey
|
||||
const ready = props.ready
|
||||
const dock = props.state.dock()
|
||||
const delay = 140
|
||||
|
||||
clear()
|
||||
setStore("ready", false)
|
||||
if (!ready) return
|
||||
if (store.ready || (!ready && !dock)) return
|
||||
if (dock) {
|
||||
setStore("ready", true)
|
||||
return
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
|
|
@ -218,7 +121,11 @@ export function SessionComposerRegion(props: {
|
|||
onCleanup(clear)
|
||||
|
||||
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 dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001)
|
||||
const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
|
||||
|
|
@ -280,8 +187,30 @@ export function SessionComposerRegion(props: {
|
|||
</Show>
|
||||
|
||||
<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
|
||||
when={promptReadyResource()}
|
||||
when={prompt.ready() || promptReadyResource()}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={rolled()} keyed>
|
||||
|
|
@ -296,35 +225,15 @@ export function SessionComposerRegion(props: {
|
|||
</div>
|
||||
)}
|
||||
</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")}
|
||||
</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>
|
||||
{(revert) => (
|
||||
<div
|
||||
|
|
@ -362,23 +271,20 @@ export function SessionComposerRegion(props: {
|
|||
fallback={
|
||||
<Show when={!props.state.blocked()}>
|
||||
<PromptInput
|
||||
controls={controls()}
|
||||
{...props.promptInput}
|
||||
controls={props.controls}
|
||||
variant={props.placement === "inline" ? "new-session" : undefined}
|
||||
ref={props.inputRef}
|
||||
newSessionWorktree={props.newSessionWorktree}
|
||||
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
||||
edit={props.followup?.edit}
|
||||
onEditLoaded={props.followup?.onEditLoaded}
|
||||
shouldQueue={props.followup?.queue}
|
||||
onQueue={props.followup?.onQueue}
|
||||
onAbort={props.followup?.onAbort}
|
||||
onSubmit={props.onSubmit}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<span>{language.t("session.child.promptDisabled")} </span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
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"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
|
|
@ -126,3 +126,13 @@ describe("todoState", () => {
|
|||
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"
|
||||
}
|
||||
|
||||
export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state === "open"
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
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 [store, setStore] = createStore({
|
||||
sessionID: params.id,
|
||||
responding: undefined as string | undefined,
|
||||
dock: todos().length > 0 && live(),
|
||||
dock: todos().length > 0 && !done() && live(),
|
||||
closing: false,
|
||||
opening: false,
|
||||
})
|
||||
|
|
@ -116,8 +119,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||
|
||||
createEffect(
|
||||
on(
|
||||
() => [todos().length, done(), live()] as const,
|
||||
([count, complete, active]) => {
|
||||
() => [params.id, todos().length, done(), live()] as const,
|
||||
([id, count, complete, active], previous) => {
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
raf = undefined
|
||||
|
||||
|
|
@ -127,6 +130,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||
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 (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
|
|
@ -181,9 +192,12 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||
permissionResponding,
|
||||
decide,
|
||||
todos,
|
||||
dock: () => store.dock,
|
||||
closing: () => store.closing,
|
||||
opening: () => store.opening,
|
||||
dock: () =>
|
||||
store.sessionID === params.id
|
||||
? 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: {
|
||||
sessionID?: string
|
||||
todos: Todo[]
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
|
|
@ -50,7 +49,7 @@ export function SessionTodoDock(props: {
|
|||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
height: 320,
|
||||
height: 78,
|
||||
})
|
||||
|
||||
const total = createMemo(() => props.todos.length)
|
||||
|
|
@ -85,7 +84,7 @@ export function SessionTodoDock(props: {
|
|||
const el = contentRef
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setStore("height", el.getBoundingClientRect().height)
|
||||
setStore("height", (height) => Math.max(height, el.scrollHeight))
|
||||
}
|
||||
update()
|
||||
createResizeObserver(el, update)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,25 @@ const btn = (accent?: boolean) =>
|
|||
"font-size": "13px",
|
||||
}) 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 = `
|
||||
[data-component="todo-stage"] {
|
||||
display: grid;
|
||||
|
|
@ -132,6 +151,7 @@ export const Playground = {
|
|||
const global = useServerSync()
|
||||
const [cfg, setCfg] = createStore({
|
||||
open: true,
|
||||
collapsed: false,
|
||||
step: 1,
|
||||
dockOpenDuration: 0.3,
|
||||
dockOpenBounce: 0,
|
||||
|
|
@ -170,7 +190,6 @@ export const Playground = {
|
|||
const countWidthDuration = () => cfg.countWidthDuration
|
||||
const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
|
||||
let frame
|
||||
let composerRef
|
||||
let scrollRef
|
||||
|
||||
const todos = createMemo<Todo[]>(() => {
|
||||
|
|
@ -196,15 +215,8 @@ export const Playground = {
|
|||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
}
|
||||
|
||||
const collapsed = () =>
|
||||
!!composerRef?.querySelector('[data-action="session-todo-toggle-button"][data-collapsed="true"]')
|
||||
|
||||
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 collapsed = () => cfg.collapsed
|
||||
const setCollapsed = (value: boolean) => setCfg("collapsed", value)
|
||||
|
||||
const openDock = () => {
|
||||
clear()
|
||||
|
|
@ -267,14 +279,21 @@ export const Playground = {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={composerRef}>
|
||||
<div>
|
||||
<SessionComposerRegion
|
||||
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}
|
||||
inputRef={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
onSubmit={() => {}}
|
||||
onResponseSubmit={pin}
|
||||
setPromptDockRef={() => {}}
|
||||
dockOpenVisualDuration={dockOpenDuration()}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue