refactor(app): split session composer (#33954)

This commit is contained in:
Brendan Allan
2026-06-25 20:23:58 +00:00
committed by GitHub
parent fae100fe72
commit a2b847e29e
11 changed files with 710 additions and 701 deletions
@@ -2,7 +2,7 @@
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 { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
import { createPromptInputHistory, PromptInput } from "./prompt-input"
function createPromptInputStoryRuntime() {
@@ -54,12 +54,6 @@ function PromptInputExample() {
paid: true,
loading: false,
},
projects: {
available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
directory: "/tmp/story",
select() {},
add() {},
},
session: {
id: "story-session",
tabs: {
@@ -142,7 +136,6 @@ function PromptInputWithOpenDock() {
paid: true,
loading: false,
},
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
session: {
id: "story-session",
tabs: {
@@ -166,22 +159,37 @@ function PromptInputWithOpenDock() {
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={() => {}}
controller={
createSessionComposerRegionController({
state,
sessionKey: () => "story-session",
sessionID: () => "story-session",
prompt: input.state,
ready: () => true,
centered: () => false,
todo: {
collapsed: () => controls.todoCollapsed,
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
},
followup: () => undefined,
revert: () => undefined,
onResponseSubmit: () => {},
openParent: () => {},
setPromptRef: () => {},
setDockRef: () => {},
})
}
promptInput={
<PromptInput
controls={inputControls}
{...input}
ref={() => {}}
newSessionWorktree=""
onNewSessionWorktreeReset={() => {}}
/>
}
/>
)
}
+4 -329
View File
@@ -4,8 +4,6 @@ import {
createEffect,
on,
Component,
splitProps,
For,
Show,
onCleanup,
createMemo,
@@ -13,10 +11,8 @@ import {
createResource,
Switch,
Match,
type ComponentProps,
type JSX,
} from "solid-js"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -30,15 +26,14 @@ import {
AgentPart,
FileAttachmentPart,
} from "@/context/prompt"
import { getProjectAvatarVariant, useLayout } from "@/context/layout"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
@@ -71,8 +66,6 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { pathKey } from "@/utils/path-key"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
export type PromptInputState = ReturnType<typeof usePrompt>
@@ -100,20 +93,6 @@ export type PromptInputControls = {
paid: boolean
loading: boolean
}
projects: {
available: {
name?: string
id?: string
worktree: string
sandboxes?: string[]
icon?: { color?: string; url?: string; override?: string }
server?: { key: string; name: string }
}[]
directory: string
server?: string
select: (worktree: string, server?: string) => void
add: (title: string, server?: string) => void
}
session: {
id?: string
tabs: {
@@ -184,6 +163,7 @@ export interface PromptInputProps {
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
toolbar?: JSX.Element
}
const EXAMPLES = [
@@ -232,7 +212,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
let projectSearchRef: HTMLInputElement | undefined
const mirror = { input: false }
const inset = 56
@@ -360,11 +339,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
() => prompt.capture(),
Math.floor(Math.random() * EXAMPLES.length),
)
const [picker, setPicker] = createStore({
projectOpen: false,
projectSearch: "",
})
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({
opacity: value,
@@ -1401,137 +1375,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}))
const newSession = () => props.variant === "new-session"
const projects = createMemo(() => props.controls.projects.available)
const projectForDirectory = (directory: string | undefined) => {
if (!directory) return
const key = pathKey(directory)
return projects().find(
(project) =>
(!project.server || project.server.key === props.controls.projects.server) &&
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
)
}
const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory))
const projectResults = createMemo(() => {
const search = picker.projectSearch.trim().toLowerCase()
if (!search) return projects()
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
})
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
const selectProject = (worktree: string, server?: string) => {
setPicker({
projectOpen: false,
projectSearch: "",
})
if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "") && server === selectedProject()?.server?.key) {
restoreFocus()
return
}
props.controls.projects.select(worktree, server)
restoreFocus()
}
const addProject = (server?: string) => {
props.controls.projects.add(language.t("command.project.open"), server)
}
const projectItems = createMemo<ComposerPickerItemState[]>(() =>
projectResults().map((project) => ({
leading: (
<ProjectAvatar
fallback={displayName(project)}
src={getProjectAvatarSource(project.id, project.icon)}
variant={getProjectAvatarVariant(project.icon?.color)}
/>
),
label: displayName(project),
selected:
selectedProject()?.worktree === project.worktree && selectedProject()?.server?.key === project.server?.key,
onSelect: () => selectProject(project.worktree, project.server?.key),
})),
)
const projectServers = createMemo(() =>
projects()
.map((project) => project.server)
.filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index),
)
const addProjectLabel = createMemo(() => language.t("session.new.project.add"))
const projectGroups = createMemo(() => {
if (projectServers().length <= 1) return
return projectServers()
.map((server) => ({
label: server!.name,
server: server!.key,
items: projectItems().filter((_, index) => projectResults()[index]?.server?.key === server!.key),
action: {
icon: "plus" as const,
label: addProjectLabel(),
onSelect: () => {
setPicker("projectOpen", false)
addProject(server!.key)
},
},
}))
.filter((group) => group.items.length > 0)
})
const projectPickerState: ComposerPickerState = {
get open() {
return picker.projectOpen
},
trigger: {
action: "prompt-project",
get icon() {
return selectedProject() ? undefined : "folder"
},
get leading() {
const project = selectedProject()
if (!project) return
return (
<ProjectAvatar
fallback={displayName(project)}
src={getProjectAvatarSource(project.id, project.icon)}
variant={getProjectAvatarVariant(project.icon?.color)}
/>
)
},
get label() {
const project = selectedProject()
return project ? displayName(project) : language.t("session.new.project.new")
},
class: "max-w-[203px]",
get style() {
return control()
},
onPress: () => setPicker("projectOpen", true),
},
get search() {
return picker.projectSearch
},
searchPlaceholder: language.t("session.new.project.search"),
clearLabel: language.t("common.clear"),
get items() {
return projectItems()
},
get groups() {
return projectGroups()
},
action: {
icon: "plus",
get label() {
return addProjectLabel()
},
onSelect: () => {
setPicker("projectOpen", false)
addProject(projectServers()[0]?.key)
},
},
onOpenChange: (open) => {
setPicker("projectOpen", open)
if (open) requestAnimationFrame(() => projectSearchRef?.focus())
},
onSearchInput: (value) => setPicker("projectSearch", value),
onSearchClear: () => setPicker("projectSearch", ""),
searchRef: (el) => (projectSearchRef = el),
}
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"),
keybind: command.keybind("agent.cycle"),
@@ -1543,15 +1387,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
restoreFocus()
},
}))
const newProjectTriggerState = createMemo<ComposerPickerTriggerState>(() => ({
action: "prompt-project",
icon: "folder-add-left",
label: language.t("session.new.project.new"),
class: "max-w-[160px]",
style: control(),
onPress: () => void addProject(),
}))
return (
<div class="relative size-full flex flex-col gap-0">
{(promptReady(), null)}
@@ -1682,9 +1517,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={showAgentControl()}>
<ComposerAgentControl state={agentControlState()} />
</Show>
<Show when={newSession() && !selectedProject()}>
<ComposerPickerTrigger state={newProjectTriggerState()} />
</Show>
{props.toolbar}
<ComposerModelControl state={modelControlState()} />
<Show when={store.mode !== "shell" && showVariantControl()}>
<div
@@ -1738,11 +1571,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Tooltip>
</div>
</DockShellForm>
<Show when={newSession() && selectedProject()}>
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
<ComposerPicker state={projectPickerState} />
</div>
</Show>
</div>
</Match>
<Match when>
@@ -2084,40 +1912,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)
}
type ComposerPickerItemState = {
icon?: IconProps["name"]
leading?: JSX.Element
label: string
selected?: boolean
onSelect: () => void
}
type ComposerPickerTriggerState = {
action: string
icon?: IconProps["name"]
leading?: JSX.Element
label: string
class?: string
style: JSX.CSSProperties | undefined
onPress: () => void
}
type ComposerPickerState = {
open: boolean
trigger: ComposerPickerTriggerState
search: string
searchPlaceholder: string
clearLabel: string
items: ComposerPickerItemState[]
groups?: { label: string; server: string; items: ComposerPickerItemState[]; action: ComposerPickerItemState }[]
action: ComposerPickerItemState
listClass?: string
searchRef: (el: HTMLInputElement) => void
onOpenChange: (open: boolean) => void
onSearchInput: (value: string) => void
onSearchClear: () => void
}
type ComposerAgentControlState = {
title: string
keybind: string
@@ -2140,125 +1934,6 @@ type ComposerModelControlState = {
onUnpaidClick: () => void
}
function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: ComposerPickerTriggerState }) {
const [local, rest] = splitProps(props, ["state", "class", "style", "onClick"])
return (
<button
{...rest}
data-action={local.state.action}
type="button"
class={`flex h-7 min-w-0 items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none ${local.state.class ?? ""}`}
style={local.state.style}
onClick={() => local.state.onPress()}
>
<Show
when={local.state.leading}
fallback={
<Show when={local.state.icon}>
{(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
}
>
{(leading) => leading()}
</Show>
<span class="min-w-0 truncate leading-5">{local.state.label}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</button>
)
}
function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) {
return (
<button
type="button"
class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={props.state.onSelect}
>
<Show
when={props.state.leading}
fallback={
<Show when={props.state.icon}>
{(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-base" />}
</Show>
}
>
{(leading) => leading()}
</Show>
<span class="min-w-0 flex-1 truncate leading-5">{props.state.label}</span>
<Show when={props.state.selected}>
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
)
}
function ComposerPicker(props: { state: ComposerPickerState }) {
return (
<KobaltePopover
open={props.state.open}
placement="bottom-start"
gutter={4}
modal={false}
onOpenChange={props.state.onOpenChange}
>
<KobaltePopover.Trigger as={ComposerPickerTrigger} state={props.state.trigger} />
<KobaltePopover.Portal>
<KobaltePopover.Content
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div class={`flex flex-col p-0.5 ${props.state.listClass ?? ""}`}>
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={props.state.searchRef}
value={props.state.search}
placeholder={props.state.searchPlaceholder}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => props.state.onSearchInput(event.currentTarget.value)}
/>
<Show when={props.state.search.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onClick={props.state.onSearchClear}
aria-label={props.state.clearLabel}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</div>
<Show
when={props.state.groups}
fallback={<For each={props.state.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For>}
>
{(groups) => (
<For each={groups()}>
{(group) => (
<div>
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{group.label}
</div>
<For each={group.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For>
<ComposerPickerMenuItem state={group.action} />
</div>
)}
</For>
)}
</Show>
</div>
<Show when={!props.state.groups}>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<ComposerPickerMenuItem state={props.state.action} />
</div>
</Show>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
)
}
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
return (
<div class="relative">
@@ -0,0 +1,278 @@
import { Popover } from "@kobalte/core/popover"
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { getProjectAvatarVariant } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { pathKey } from "@/utils/path-key"
export type PromptProject = {
name?: string
id?: string
worktree: string
sandboxes?: string[]
icon?: { color?: string; url?: string; override?: string }
server?: { key: string; name: string }
}
export type PromptProjectControls = {
available: PromptProject[]
directory: string
server?: string
select: (worktree: string, server?: string) => void
add: (title: string, server?: string) => void
}
export function createPromptProjectController(input: {
controls: Accessor<PromptProjectControls>
onDone: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ open: false, search: "" })
let searchRef: HTMLInputElement | undefined
const selected = () => {
const key = pathKey(input.controls().directory)
return input
.controls()
.available.find(
(project) =>
(!project.server || project.server.key === input.controls().server) &&
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
)
}
const projects = () => {
const search = store.search.trim().toLowerCase()
if (!search) return input.controls().available
return input.controls().available.filter((project) => displayName(project).toLowerCase().includes(search))
}
const servers = () =>
input
.controls()
.available.map((project) => project.server)
.filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index)
const close = () => {
setStore({ open: false, search: "" })
input.onDone()
}
const select = (project: PromptProject) => {
if (
pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
project.server?.key !== selected()?.server?.key
) {
input.controls().select(project.worktree, project.server?.key)
}
close()
}
const add = (server?: string) => {
setStore("open", false)
input.controls().add(language.t("command.project.open"), server)
}
return {
selected,
projects,
servers,
open: () => store.open,
search: () => store.search,
labels: {
add: () => language.t("session.new.project.add"),
clear: () => language.t("common.clear"),
new: () => language.t("session.new.project.new"),
search: () => language.t("session.new.project.search"),
},
add,
select,
setOpen(open: boolean) {
setStore("open", open)
if (open) requestAnimationFrame(() => searchRef?.focus())
},
setSearch(value: string) {
setStore("search", value)
},
setSearchRef(el: HTMLInputElement) {
searchRef = el
},
}
}
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
export function PromptProjectSelector(props: { controller: PromptProjectController }) {
return (
<Popover
open={props.controller.open()}
placement="bottom-start"
gutter={4}
modal={false}
onOpenChange={(open) => props.controller.setOpen(open)}
>
<Popover.Trigger as={ProjectTrigger} controller={props.controller} />
<Popover.Portal>
<Popover.Content
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(el) => props.controller.setSearchRef(el)}
value={props.controller.search()}
placeholder={props.controller.labels.search()}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => props.controller.setSearch(event.currentTarget.value)}
/>
<Show when={props.controller.search().trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onClick={() => props.controller.setSearch("")}
aria-label={props.controller.labels.clear()}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</div>
<Show
when={props.controller.servers().length > 1}
fallback={
<For each={props.controller.projects()}>
{(project) => (
<ProjectItem
project={project}
selected={props.controller.selected()}
onSelect={props.controller.select}
/>
)}
</For>
}
>
<For each={props.controller.servers()}>
{(server) => (
<div>
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{server!.name}
</div>
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
{(project) => (
<ProjectItem
project={project}
selected={props.controller.selected()}
onSelect={props.controller.select}
/>
)}
</For>
<ProjectAction
label={props.controller.labels.add()}
onSelect={() => props.controller.add(server!.key)}
/>
</div>
)}
</For>
</Show>
</div>
<Show when={props.controller.servers().length <= 1}>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<ProjectAction
label={props.controller.labels.add()}
onSelect={() => props.controller.add(props.controller.servers()[0]?.key)}
/>
</div>
</Show>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
export function PromptProjectAddButton(props: { controller: PromptProjectController }) {
return (
<button
data-action="prompt-project"
type="button"
class="flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => props.controller.add()}
>
<Icon name="folder-add-left" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate leading-5">{props.controller.labels.new()}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</button>
)
}
function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
const [local, rest] = splitProps(props, ["controller", "class", "onClick"])
const project = () => local.controller.selected()
return (
<button
{...rest}
data-action="prompt-project"
type="button"
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => local.controller.setOpen(true)}
>
<Show
when={project()}
fallback={<Icon name="folder-add-left" size="small" class="shrink-0 text-v2-icon-icon-muted" />}
>
{(item) => (
<ProjectAvatar
fallback={displayName(item())}
src={getProjectAvatarSource(item().id, item().icon)}
variant={getProjectAvatarVariant(item().icon?.color)}
/>
)}
</Show>
<span class="min-w-0 truncate leading-5">
{project() ? displayName(project()!) : local.controller.labels.new()}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</button>
)
}
function ProjectItem(props: {
project: PromptProject
selected?: PromptProject
onSelect: (project: PromptProject) => void
}) {
return (
<button
type="button"
class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => props.onSelect(props.project)}
>
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
<span class="min-w-0 flex-1 truncate leading-5">{displayName(props.project)}</span>
<Show
when={
props.selected?.worktree === props.project.worktree &&
props.selected?.server?.key === props.project.server?.key
}
>
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
)
}
function ProjectAction(props: { label: string; onSelect: () => void }) {
return (
<button
type="button"
class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={props.onSelect}
>
<Icon name="plus" size="small" />
<span class="min-w-0 flex-1 truncate leading-5">{props.label}</span>
</button>
)
}
+57 -29
View File
@@ -1,19 +1,23 @@
import { createEffect, createMemo, onMount, untrack } from "solid-js"
import { Show, createEffect, createMemo, createResource, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useSearchParams } from "@solidjs/router"
import { NewSessionDesignView } from "@/components/session"
import { PromptInput } from "@/components/prompt-input"
import {
PromptProjectAddButton,
PromptProjectSelector,
createPromptProjectController,
} from "@/components/prompt-project-selector"
import { useComments } from "@/context/comments"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useServerSync } from "@/context/server-sync"
import {
createSessionComposerControls,
createSessionComposerState,
SessionComposerRegion,
} from "@/pages/session/composer"
import { useLanguage } from "@/context/language"
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
/**
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
@@ -26,6 +30,7 @@ export default function NewSessionPage() {
const sync = useSync()
const serverSync = useServerSync()
const comments = useComments()
const language = useLanguage()
const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
@@ -33,12 +38,16 @@ export default function NewSessionPage() {
let inputRef: HTMLDivElement | undefined
const composer = createSessionComposerState()
const composerControls = createSessionComposerControls({
const inputController = createPromptInputController({
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions,
})
const projectControls = createPromptProjectControls()
const projectController = createPromptProjectController({
controls: projectControls,
onDone: () => inputRef?.focus(),
})
const [store, setStore] = createStore({
worktree: "main",
@@ -61,9 +70,15 @@ export default function NewSessionPage() {
})
})
onMount(() => {
createEffect(() => {
if (!prompt.ready()) return
requestAnimationFrame(() => inputRef?.focus())
})
const ready = Promise.resolve()
const [promptReady] = createResource(
() => prompt.ready.promise ?? ready,
(promise) => promise.then(() => true),
)
return (
<div class="relative size-full overflow-hidden flex flex-col">
@@ -71,26 +86,39 @@ export default function NewSessionPage() {
<div class="@container relative flex flex-col min-h-0 h-full bg-background-stronger flex-1">
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
<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"
onResponseSubmit={() => {}}
setPromptDockRef={() => {}}
/>
<div class={NEW_SESSION_CONTENT_WIDTH}>
<Show
when={prompt.ready() || promptReady()}
fallback={
<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 pointer-events-none">
{language.t("prompt.loading")}
</div>
}
>
<div class="flex flex-col gap-3">
<PromptInput
controls={inputController()}
variant="new-session"
ref={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
onSubmit={() => comments.clear()}
toolbar={
<Show when={!projectController.selected()}>
<PromptProjectAddButton controller={projectController} />
</Show>
}
/>
<Show when={projectController.selected()}>
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
<PromptProjectSelector controller={projectController} />
</div>
</Show>
</div>
</Show>
</div>
</NewSessionDesignView>
</div>
</div>
+70 -60
View File
@@ -42,10 +42,12 @@ import { useServerSDK } from "@/context/server-sdk"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import {
createSessionComposerControls,
createSessionComposerState,
createPromptInputController,
createSessionComposerController,
createSessionComposerRegionController,
SessionComposerRegion,
} from "@/pages/session/composer"
import {
@@ -156,8 +158,8 @@ export default function Page() {
},
})
const composer = createSessionComposerState()
const composerControls = createSessionComposerControls({
const composer = createSessionComposerController()
const inputController = createPromptInputController({
sessionKey,
sessionID: () => params.id,
queryOptions: serverSync().queryOptions,
@@ -1569,62 +1571,28 @@ export default function Page() {
useUsageExceededDialogs()
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(),
const composerRegion = () => {
const controller = createSessionComposerRegionController({
state: composer,
sessionKey,
sessionID: () => params.id,
prompt,
ready: () => !store.deferRender && messagesReady(),
centered,
todo: {
collapsed: () => view().todoCollapsed.get(),
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
}}
ready={!store.deferRender && messagesReady()}
centered={placement === "dock" && centered()}
placement={placement}
openParent={() => {
const id = info()?.parentID
if (!id) return
navigate(
params.serverKey
? sessionHref(requireServerKey(params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
}}
onResponseSubmit={resumeScroll}
followup={
},
followup: () =>
params.id && !isChildSession()
? {
queue: queueEnabled,
items: followupDock(),
sending: sendingFollowup(),
edit: editingFollowup(),
onQueue: queueFollowup,
onAbort: () => {
const id = params.id
if (!id) return
setFollowup("paused", id, true)
},
onSend: (id) => {
void sendFollowup(params.id!, id, { manual: true })
},
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
onEdit: editFollowup,
onEditLoaded: clearFollowupEdit,
}
: undefined
}
revert={
: undefined,
revert: () =>
rolled().length > 0
? {
items: rolled(),
@@ -1632,13 +1600,53 @@ export default function Page() {
disabled: reverting(),
onRestore: restore,
}
: undefined
}
setPromptDockRef={(el) => {
: undefined,
onResponseSubmit: resumeScroll,
openParent: () => {
const id = info()?.parentID
if (!id) return
navigate(
params.serverKey
? sessionHref(requireServerKey(params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
},
setPromptRef: (el) => {
inputRef = el
},
setDockRef: (el) => {
promptDock = el
}}
/>
)
},
})
return (
<SessionComposerRegion
controller={controller}
promptInput={
<PromptInput
controls={inputController()}
ref={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
resumeScroll()
}}
edit={editingFollowup()}
onEditLoaded={clearFollowupEdit}
shouldQueue={queueEnabled}
onQueue={queueFollowup}
onAbort={() => {
const id = params.id
if (!id) return
setFollowup("paused", id, true)
}}
/>
}
/>
)
}
const mobileTabs = (compact = false, bottom = false) => (
<Tabs value={store.mobileTab} class="h-auto">
@@ -1775,7 +1783,9 @@ export default function Page() {
</Switch>
</div>
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{composerRegion("dock")}</Show>
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>
{(_) => composerRegion()}
</Show>
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
</div>
@@ -1,3 +1,4 @@
export { SessionComposerRegion } from "./session-composer-region"
export { createSessionComposerControls } from "./session-composer-controls"
export { createSessionComposerState } from "./session-composer-state"
export { createPromptInputController, createPromptProjectControls } from "./session-composer-controls"
export { createSessionComposerController } from "./session-composer-state"
export { createSessionComposerRegionController } from "./session-composer-region-controller"
@@ -3,6 +3,7 @@ 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 type { PromptProjectControls } from "@/components/prompt-project-selector"
import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal } from "@/context/global"
import { useLayout } from "@/context/layout"
@@ -17,26 +18,55 @@ import { useTabs } from "@/context/tabs"
import { useProviders } from "@/hooks/use-providers"
import { pathKey } from "@/utils/path-key"
export function createSessionComposerControls(input: {
export function createPromptInputController(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 sync = useSync()
const sdk = useSDK()
const view = layout.view(input.sessionKey)
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)))
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,
},
session: {
id: input.sessionID(),
tabs: layout.tabs(input.sessionKey),
reviewPanel: view.reviewPanel,
},
newLayoutDesigns: settings.general.newLayoutDesigns(),
}))
}
export function createPromptProjectControls() {
const navigate = useNavigate()
const layout = useLayout()
const server = useServer()
const serverSDK = useServerSDK()
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 projectServer = () => serverSDK().server
const projectServerCtx = createMemo(() => global.ensureServerCtx(projectServer()))
const projects = createMemo(() => {
@@ -44,17 +74,13 @@ export function createSessionComposerControls(input: {
return search.draftId ? projectServerCtx().projects.list() : layout.projects.list()
}
return server.list.flatMap((conn) => {
const server = { key: ServerConnection.key(conn), name: serverName(conn) }
const item = { key: ServerConnection.key(conn), name: serverName(conn) }
return global
.ensureServerCtx(conn)
.projects.list()
.map((project) => ({ ...project, server }))
.map((project) => ({ ...project, server: item }))
})
})
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, serverKey?: string) => {
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
if (search.draftId) {
@@ -94,32 +120,11 @@ export function createSessionComposerControls(input: {
})
}
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,
server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
select: selectProject,
add: addProject,
},
session: {
id: input.sessionID(),
tabs: layout.tabs(input.sessionKey),
reviewPanel: view.reviewPanel,
},
newLayoutDesigns: settings.general.newLayoutDesigns(),
return createMemo<PromptProjectControls>(() => ({
available: projects(),
directory: sdk().directory,
server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
select: selectProject,
add: addProject,
}))
}
@@ -0,0 +1,145 @@
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { PromptInputState } from "@/components/prompt-input"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import type { SessionComposerController } from "./session-composer-state"
export type SessionComposerFollowupDock = {
items: { id: string; text: string }[]
sending?: string
onSend: (id: string) => void
onEdit: (id: string) => void
}
export type SessionComposerRevertDock = {
items: { id: string; text: string }[]
restoring?: string
disabled?: boolean
onRestore: (id: string) => void
}
export function createSessionComposerRegionController(input: {
state: SessionComposerController
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
prompt: PromptInputState
ready: Accessor<boolean>
centered: Accessor<boolean>
todo: {
collapsed: Accessor<boolean>
onToggle: () => void
}
followup: Accessor<SessionComposerFollowupDock | undefined>
revert: Accessor<SessionComposerRevertDock | undefined>
onResponseSubmit: () => void
openParent: () => void
setPromptRef: (el: HTMLDivElement) => void
setDockRef: (el: HTMLDivElement) => void
}) {
const sync = useSync()
const [store, setStore] = createStore({
ready: input.ready() || input.state.dock(),
height: 320,
body: undefined as HTMLDivElement | undefined,
})
let timer: number | undefined
let frame: number | undefined
const clear = () => {
if (timer !== undefined) window.clearTimeout(timer)
if (frame !== undefined) cancelAnimationFrame(frame)
timer = undefined
frame = undefined
}
createEffect(() => {
input.sessionKey()
const ready = input.ready()
const dock = input.state.dock()
clear()
if (store.ready || (!ready && !dock)) return
if (dock) {
setStore("ready", true)
return
}
frame = requestAnimationFrame(() => {
frame = undefined
timer = window.setTimeout(() => {
setStore("ready", true)
timer = undefined
}, 140)
})
})
createEffect(() => {
if (!input.prompt.ready()) return
setSessionHandoff(input.sessionKey(), {
prompt: input.prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim(),
})
})
createEffect(() => {
const el = store.body
if (!el) return
const update = () => setStore("height", el.getBoundingClientRect().height)
createResizeObserver(el, update)
update()
})
onCleanup(clear)
const parentID = createMemo(() => {
const id = input.sessionID()
return id ? sync().session.get(id)?.parentID : undefined
})
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
const progress = useSpring(
() => (open() ? 1 : 0),
{ visualDuration: 0.3, bounce: 0 },
() => `${input.sessionKey()}\0${store.ready}`,
)
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
const ready = Promise.resolve()
const [promptReady] = createResource(
() => input.prompt.ready.promise ?? ready,
(promise) => promise.then(() => true),
)
return {
state: input.state,
centered: input.centered,
todo: input.todo,
followup: input.followup,
revert: input.revert,
onResponseSubmit: input.onResponseSubmit,
openParent: input.openParent,
setPromptRef: input.setPromptRef,
setDockRef: input.setDockRef,
parentID,
child: () => !!parentID(),
showComposer: () => !input.state.blocked() || !!parentID(),
handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
promptReady: () => input.prompt.ready() || promptReady(),
dock: () => (store.ready && input.state.dock()) || value() > 0.001,
dockProgress: value,
dockHeight: () => Math.max(78, store.height),
lift: () => (input.revert()?.items.length ? 18 : 36 * value()),
setDockBodyRef: (el: HTMLDivElement) => setStore("body", el),
}
}
export type SessionComposerRegionController = ReturnType<typeof createSessionComposerRegionController>
@@ -1,216 +1,83 @@
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { PromptInput, type PromptInputControls, type PromptInputProps } from "@/components/prompt-input"
import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
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"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
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 type { SessionComposerRegionController } from "./session-composer-region-controller"
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
onResponseSubmit: () => void
followup?: {
queue: () => boolean
items: { id: string; text: string }[]
sending?: string
edit?: { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] }
onQueue: (draft: FollowupDraft) => void
onAbort: () => void
onSend: (id: string) => void
onEdit: (id: string) => void
onEditLoaded: () => void
}
revert?: {
items: { id: string; text: string }[]
restoring?: string
disabled?: boolean
onRestore: (id: string) => void
}
setPromptDockRef: (el: HTMLDivElement) => void
controller: SessionComposerRegionController
promptInput: JSX.Element
}) {
const prompt = props.promptInput.state ?? usePrompt()
const language = useLanguage()
const sync = useSync()
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())
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(props.sessionKey, { prompt: previewPrompt() })
})
const [store, setStore] = createStore({
ready: props.ready || props.state.dock(),
height: 320,
body: undefined as HTMLDivElement | undefined,
})
let timer: number | undefined
let frame: number | undefined
const clear = () => {
if (timer !== undefined) {
window.clearTimeout(timer)
timer = undefined
}
if (frame !== undefined) {
cancelAnimationFrame(frame)
frame = undefined
}
const controller = props.controller
const rolled = () => {
const revert = controller.revert()
return revert?.items.length ? revert : undefined
}
createEffect(() => {
props.sessionKey
const ready = props.ready
const dock = props.state.dock()
const delay = 140
clear()
if (store.ready || (!ready && !dock)) return
if (dock) {
setStore("ready", true)
return
}
frame = requestAnimationFrame(() => {
frame = undefined
timer = window.setTimeout(() => {
setStore("ready", true)
timer = undefined
}, delay)
})
})
onCleanup(clear)
const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing())
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))
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
const full = createMemo(() => Math.max(78, store.height))
createEffect(() => {
const el = store.body
if (!el) return
const update = () => setStore("height", el.getBoundingClientRect().height)
createResizeObserver(store.body, update)
update()
})
const ready = Promise.resolve()
const [promptReadyResource] = createResource(
() => prompt.ready.promise ?? ready,
(promise) => promise.then(() => true),
)
return (
<div
ref={props.setPromptDockRef}
ref={controller.setDockRef}
data-component="session-prompt-dock"
classList={{
"w-full flex flex-col justify-center items-center pointer-events-none": true,
"shrink-0 pb-3 bg-background-stronger": props.placement !== "inline",
}}
class="w-full shrink-0 flex flex-col justify-center items-center pb-3 bg-background-stronger pointer-events-none"
>
<div
classList={{
"w-full pointer-events-auto": true,
"px-3": props.placement !== "inline",
[NEW_SESSION_CONTENT_WIDTH]: props.placement === "inline",
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
"w-full px-3 pointer-events-auto": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": controller.centered(),
}}
>
<Show when={props.state.questionRequest()} keyed>
<Show when={controller.state.questionRequest()} keyed>
{(request) => (
<div>
<SessionQuestionDock request={request} onSubmit={props.onResponseSubmit} />
<SessionQuestionDock request={request} onSubmit={controller.onResponseSubmit} />
</div>
)}
</Show>
<Show when={props.state.permissionRequest()} keyed>
<Show when={controller.state.permissionRequest()} keyed>
{(request) => (
<div>
<SessionPermissionDock
request={request}
responding={props.state.permissionResponding()}
responding={controller.state.permissionResponding()}
onDecide={(response) => {
props.onResponseSubmit()
props.state.decide(response)
controller.onResponseSubmit()
controller.state.decide(response)
}}
/>
</div>
)}
</Show>
<Show when={showComposer()}>
<Show when={dock()}>
<Show when={controller.showComposer()}>
<Show when={controller.dock()}>
<div
classList={{
"overflow-hidden": true,
"pointer-events-none": value() < 0.98,
"pointer-events-none": controller.dockProgress() < 0.98,
}}
style={{
"max-height": `${full() * value()}px`,
"max-height": `${controller.dockHeight() * controller.dockProgress()}px`,
}}
>
<div ref={(el) => setStore("body", el)}>
<div ref={controller.setDockBodyRef}>
<SessionTodoDock
todos={props.state.todos()}
collapsed={props.todo.collapsed}
onToggle={props.todo.onToggle}
todos={controller.state.todos()}
collapsed={controller.todo.collapsed()}
onToggle={controller.todo.onToggle}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={value()}
dockProgress={controller.dockProgress()}
/>
</div>
</div>
</Show>
<Show
when={prompt.ready() || promptReadyResource()}
when={controller.promptReady()}
fallback={
<>
<Show when={rolled()} keyed>
@@ -227,9 +94,9 @@ export function SessionComposerRegion(props: {
</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"
style={{ "margin-top": `${-36 * value()}px` }}
style={{ "margin-top": `${-36 * controller.dockProgress()}px` }}
>
{handoffPrompt() || language.t("prompt.loading")}
{controller.handoffPrompt() || language.t("prompt.loading")}
</div>
</>
}
@@ -238,7 +105,7 @@ export function SessionComposerRegion(props: {
{(revert) => (
<div
style={{
"margin-top": `${-36 * value()}px`,
"margin-top": `${-36 * controller.dockProgress()}px`,
}}
>
<SessionRevertDock
@@ -255,44 +122,35 @@ export function SessionComposerRegion(props: {
"relative z-30": true,
}}
style={{
"margin-top": `${-lift()}px`,
"margin-top": `${-controller.lift()}px`,
}}
>
<Show when={props.followup?.items.length}>
<Show when={controller.followup()?.items.length}>
<SessionFollowupDock
items={props.followup!.items}
sending={props.followup!.sending}
onSend={props.followup!.onSend}
onEdit={props.followup!.onEdit}
items={controller.followup()!.items}
sending={controller.followup()!.sending}
onSend={controller.followup()!.onSend}
onEdit={controller.followup()!.onEdit}
/>
</Show>
<Show
when={child()}
when={controller.child()}
fallback={
<Show when={!props.state.blocked()}>
<PromptInput
{...props.promptInput}
controls={props.controls}
variant={props.placement === "inline" ? "new-session" : undefined}
edit={props.followup?.edit}
onEditLoaded={props.followup?.onEditLoaded}
shouldQueue={props.followup?.queue}
onQueue={props.followup?.onQueue}
onAbort={props.followup?.onAbort}
/>
<Show when={!controller.state.blocked()}>
{props.promptInput}
</Show>
}
>
<div
ref={props.promptInput.ref}
ref={controller.setPromptRef}
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>
<Show when={parentID() && props.openParent}>
<Show when={controller.parentID()}>
<button
type="button"
class="text-text-base transition-colors hover:text-text-strong"
onClick={props.openParent}
onClick={controller.openParent}
>
{language.t("session.child.backToParent")}
</button>
@@ -25,7 +25,7 @@ export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state
const idle = { type: "idle" as const }
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
@@ -201,4 +201,4 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
}
}
export type SessionComposerState = ReturnType<typeof createSessionComposerState>
export type SessionComposerController = ReturnType<typeof createSessionComposerController>
@@ -3,7 +3,13 @@ import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { Todo } from "@opencode-ai/sdk/v2"
import { useServerSync } from "@/context/global-sync"
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
import { PromptInput } from "@/components/prompt-input"
import { usePrompt } from "@/context/prompt"
import {
SessionComposerRegion,
createSessionComposerController,
createSessionComposerRegionController,
} from "@/pages/session/composer"
export default {
title: "UI/Todo Panel Motion",
@@ -60,7 +66,6 @@ const controls = {
paid: true,
loading: false,
},
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
session: {
id: "story-session",
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
@@ -149,6 +154,7 @@ const css = `
export const Playground = {
render: () => {
const global = useServerSync()
const prompt = usePrompt()
const [cfg, setCfg] = createStore({
open: true,
collapsed: false,
@@ -188,7 +194,7 @@ export const Playground = {
const countMask = () => cfg.countMask
const countMaskHeight = () => cfg.countMaskHeight
const countWidthDuration = () => cfg.countWidthDuration
const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
let frame
let scrollRef
@@ -217,7 +223,6 @@ export const Playground = {
const collapsed = () => cfg.collapsed
const setCollapsed = (value: boolean) => setCfg("collapsed", value)
const openDock = () => {
clear()
setCfg("open", true)
@@ -281,36 +286,32 @@ export const Playground = {
<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}
onResponseSubmit={pin}
setPromptDockRef={() => {}}
dockOpenVisualDuration={dockOpenDuration()}
dockOpenBounce={dockOpenBounce()}
dockCloseVisualDuration={dockCloseDuration()}
dockCloseBounce={dockCloseBounce()}
drawerExpandVisualDuration={drawerExpandDuration()}
drawerExpandBounce={drawerExpandBounce()}
drawerCollapseVisualDuration={drawerCollapseDuration()}
drawerCollapseBounce={drawerCollapseBounce()}
subtitleDuration={subtitleDuration()}
subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()}
subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()}
countDuration={countDuration()}
countMask={countMask()}
countMaskHeight={countMaskHeight()}
countWidthDuration={countWidthDuration()}
controller={
createSessionComposerRegionController({
state,
sessionKey: () => "story-session",
sessionID: () => "story-session",
prompt,
ready: () => true,
centered: () => false,
todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) },
followup: () => undefined,
revert: () => undefined,
onResponseSubmit: pin,
openParent: () => {},
setPromptRef: () => {},
setDockRef: () => {},
})
}
promptInput={
<PromptInput
controls={controls}
submission={{ abort: () => {}, handleSubmit: (event) => event.preventDefault() }}
ref={() => {}}
newSessionWorktree=""
onNewSessionWorktreeReset={() => {}}
/>
}
/>
</div>
</div>