Compare commits

...

2 Commits

Author SHA1 Message Date
Brendan Allan bcd29d6d2e feat(cli): embed web ui 2026-08-10 18:45:00 +08:00
Brendan Allan c7852ef0fd refactor(app): establish v2 session controller (#39233) 2026-08-10 08:50:24 +00:00
22 changed files with 1238 additions and 321 deletions
+129 -141
View File
@@ -58,7 +58,7 @@ import { ServerConnection, serverName, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal"
import { TerminalProvider } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
import { useSettingsCommand } from "@/components/settings-dialog"
@@ -71,11 +71,11 @@ import {
createSessionComposerRegionController,
SessionComposerRegion,
} from "@/pages/session/composer"
import { createOpenReviewFile, createSessionTabs, createSizing, shouldShowFileTree } from "@/pages/session/helpers"
import { createOpenReviewFile, createSizing, shouldShowFileTree } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionController } from "@/pages/session/session-controller"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import {
clampSessionPanelWidth,
@@ -102,7 +102,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
import { createSessionLineage } from "./session/session-lineage"
type FollowupItem = FollowupDraft & { id: string }
@@ -367,20 +366,24 @@ export default function Page() {
const prompt = usePrompt()
const comments = useComments()
const command = useCommand()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const navigate = useNavigate()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
const reviewMode = () => view().review.mode() ?? "git"
const reviewFile = () => view().review.file()
const sessionOwnership = createSessionOwnership(sessionKey)
const isDesktop = createMediaQuery("(min-width: 768px)")
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
const canReview = createMemo(() => !!sync().project)
const controller = createSessionController({
review: isDesktop,
hasReview: canReview,
fileBrowser: (sessionID) => newSessionDesign() && isDesktop() && !!sessionID,
})
const reviewMode = () => controller.layout.view().review.mode() ?? "git"
const reviewFile = () => controller.layout.view().review.file()
createEffect(() => {
if (!prompt.ready()) return
untrack(() => {
if (params.id) return
if (controller.identity.params.id) return
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
@@ -401,17 +404,19 @@ export default function Page() {
const composer = createSessionComposerController()
const inputController = createPromptInputController({
sessionKey,
sessionID: () => params.id,
sessionKey: controller.identity.sessionKey,
sessionID: () => controller.identity.params.id,
queryOptions: serverSync().queryOptions,
})
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
const sessionPanelKey = createMemo(() => (params.id ? `${serverSDK().scope}\0${params.id}` : undefined))
const workspaceTabs = createMemo(() => layout.tabs(controller.identity.workspaceKey))
const sessionPanelKey = createMemo(() =>
controller.identity.params.id ? `${serverSDK().scope}\0${controller.identity.params.id}` : undefined,
)
createEffect(
on(
() => params.id,
() => controller.identity.params.id,
(id, prev) => {
if (!id) return
if (prev) return
@@ -431,13 +436,13 @@ export default function Page() {
const from = workspaceTabs().tabs()
if (from.all.length === 0 && !from.active) return
const current = tabs().tabs()
const current = controller.layout.tabs().tabs()
if (current.all.length > 0 || current.active) return
const all = normalizeTabs(from.all)
const active = from.active ? normalizeTab(from.active) : undefined
tabs().setAll(all)
tabs().setActive(active && all.includes(active) ? active : all[0])
const all = controller.tabs.normalizeAll(from.all)
const active = from.active ? controller.tabs.normalize(from.active) : undefined
controller.layout.tabs().setAll(all)
controller.layout.tabs().setActive(active && all.includes(active) ? active : all[0])
workspaceTabs().setAll([])
workspaceTabs().setActive(undefined)
@@ -446,11 +451,12 @@ export default function Page() {
),
)
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id)
const terminalOpen = createMemo(() => view().terminal.opened())
const desktopReviewOpen = createMemo(() => isDesktop() && controller.layout.view().reviewPanel.opened())
const desktopV2ReviewOpen = createMemo(
() => newSessionDesign() && desktopReviewOpen() && !!controller.identity.params.id,
)
const terminalOpen = createMemo(() => controller.layout.view().terminal.opened())
const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen())
const desktopInlineTerminalOnlyOpen = createMemo(
() => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(),
@@ -511,53 +517,21 @@ export default function Page() {
}),
)
function normalizeTab(tab: string) {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
function normalizeTabs(list: string[]) {
const seen = new Set<string>()
const next: string[] = []
for (const item of list) {
const value = normalizeTab(item)
if (seen.has(value)) continue
seen.add(value)
next.push(value)
}
return next
}
const openReviewPanel = () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
if (!controller.layout.view().reviewPanel.opened()) controller.layout.view().reviewPanel.open()
}
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID)
const canReview = createMemo(() => !!sync().project)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview: canReview,
})
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const timeline = createTimelineModel({ sessionID: () => params.id, revertMessageID })
const timeline = createTimelineModel({ session: controller })
const historyLoading = timeline.history.loading
const historyMore = timeline.history.more
const lastUserMessage = timeline.lastUserMessage
const messages = timeline.messages
const messagesReady = timeline.ready
const sessionSync = timeline.resource
const userMessages = timeline.userMessages
const visibleUserMessages = timeline.visibleUserMessages
createEffect(() => {
const tab = activeFileTab()
const tab = controller.tabs.activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
@@ -577,7 +551,7 @@ export default function Page() {
let restoredModelSession: string | undefined
createEffect(() => {
const id = params.id
const id = controller.identity.params.id
if (!id || !prompt.ready() || !local.session.ready()) return
if (restoredModelSession !== id) {
restoredModelSession = id
@@ -588,7 +562,7 @@ export default function Page() {
createEffect(
on(
() => ({ dir: sdk().directory, id: params.id }),
() => ({ dir: sdk().directory, id: controller.identity.params.id }),
(next, prev) => {
if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return
@@ -620,10 +594,10 @@ export default function Page() {
)
createComputed((prev) => {
const key = sessionKey()
const key = controller.identity.sessionKey()
if (key !== prev) {
setStore("deferRender", true)
const owner = sessionOwnership.capture()
const owner = controller.ownership.capture()
requestAnimationFrame(() => {
setTimeout(() => owner.run(() => setStore("deferRender", false)), 0)
})
@@ -670,7 +644,8 @@ export default function Page() {
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() ||
(desktopReviewOpen() && (activeTab() === "review" || (newSessionDesign() && !!activeFileTab())))
(desktopReviewOpen() &&
(controller.tabs.activeTab() === "review" || (newSessionDesign() && !!controller.tabs.activeFileTab())))
: store.mobileTab === "changes",
)
const vcsMode = createMemo<VcsMode | undefined>(() => {
@@ -904,7 +879,7 @@ export default function Page() {
createEffect(
on(
() => {
const id = params.id
const id = controller.identity.params.id
return [
sdk().directory,
id,
@@ -925,7 +900,7 @@ export default function Page() {
todoFrame = undefined
todoTimer = window.setTimeout(() => {
todoTimer = undefined
if (sdk().directory !== dir || params.id !== id) return
if (sdk().directory !== dir || controller.identity.params.id !== id) return
untrack(() => {
void sync().session.todo(id, cached ? { force: true } : undefined)
})
@@ -950,7 +925,7 @@ export default function Page() {
createEffect(
on(
sessionKey,
controller.identity.sessionKey,
() => {
setStore(sessionViewState())
setUi("pendingMessage", undefined)
@@ -1071,7 +1046,7 @@ export default function Page() {
}
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (composer.blocked() || isChildSession()) return
if (composer.blocked() || controller.data.isChild()) return
const input = inputRef
if (!input) return
input.focus()
@@ -1088,12 +1063,12 @@ export default function Page() {
if (list.includes(mode)) return
const next = list[0]
if (!next) return
view().review.setMode(next)
controller.layout.view().review.setMode(next)
})
createEffect(
on(
() => sync().data.session_status[params.id ?? ""]?.type,
() => sync().data.session_status[controller.identity.params.id ?? ""]?.type,
(next, prev) => {
if (next !== "idle" || prev === undefined || prev === "idle") return
refreshVcs()
@@ -1112,7 +1087,7 @@ export default function Page() {
createEffect(
on(
sessionKey,
controller.identity.sessionKey,
() => {
setTree({
reviewScroll: undefined,
@@ -1129,17 +1104,16 @@ export default function Page() {
}
const focusInput = () => {
if (isChildSession()) return
if (controller.data.isChild()) return
inputRef?.focus()
}
useComposerCommands()
useSessionCommands({
session: controller,
navigateMessageByOffset,
setActiveMessage,
focusInput,
review: reviewTab,
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
})
command.register("session-palette", () => [
{
@@ -1153,8 +1127,8 @@ export default function Page() {
const openReviewFile = createOpenReviewFile({
showAllFiles,
tabForPath: file.tab,
openTab: tabs().open,
setActive: tabs().setActive,
openTab: controller.layout.tabs().open,
setActive: controller.layout.tabs().setActive,
loadFile: file.load,
})
@@ -1174,7 +1148,7 @@ export default function Page() {
options={changesOptions()}
current={reviewMode()}
label={changesLabel}
onSelect={(option) => option && view().review.setMode(option)}
onSelect={(option) => option && controller.layout.view().review.setMode(option)}
variant="ghost"
size="small"
valueClass="text-14-medium"
@@ -1195,7 +1169,7 @@ export default function Page() {
label={changesLabel}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && view().review.setMode(option)}
onSelect={(option) => option && controller.layout.view().review.setMode(option)}
/>
)
}
@@ -1265,7 +1239,7 @@ export default function Page() {
title={changesTitle()}
empty={reviewEmpty(input)}
diffs={reviewDiffs}
view={view}
view={controller.layout.view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
@@ -1371,7 +1345,7 @@ export default function Page() {
createEffect(
on(
activeFileTab,
controller.tabs.activeFileTab,
(active) => {
if (!active) return
if (fileTreeTab() !== "changes") return
@@ -1410,15 +1384,15 @@ export default function Page() {
const top = reviewDiffTop(path)
if (top === undefined) return false
view().setScroll("review", { x: root.scrollLeft, y: top })
controller.layout.view().setScroll("review", { x: root.scrollLeft, y: top })
root.scrollTo({ top, behavior: "auto" })
return true
}
const focusReviewDiff = (path: string) => {
openReviewPanel()
view().review.openPath(path)
view().review.setFile(path)
controller.layout.view().review.openPath(path)
controller.layout.view().review.setFile(path)
setTree("pendingDiff", path)
}
@@ -1480,7 +1454,7 @@ export default function Page() {
on(
() => sdk().directory,
() => {
const tab = activeFileTab()
const tab = controller.tabs.activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (!path) return
@@ -1496,7 +1470,7 @@ export default function Page() {
})
createEffect(
on(
() => params.id,
() => controller.identity.params.id,
(id, previous) => {
if (!id || !previous || id === previous) return
if (location.hash || store.messageId || ui.pendingMessage) return
@@ -1588,7 +1562,7 @@ export default function Page() {
const historyRequests = new Set<string>()
let historyContinuationFrame: number | undefined
const loadOlder = async () => {
const owner = sessionOwnership.capture()
const owner = controller.ownership.capture()
if (historyLoading() || historyRequests.has(owner.key)) return
historyRequests.add(owner.key)
const before = timeline.messages().length
@@ -1610,7 +1584,7 @@ export default function Page() {
}
const onHistoryScroll = () => {
if (
historyRequests.has(sessionOwnership.key()) ||
historyRequests.has(controller.ownership.key()) ||
historyLoading() ||
!autoScroll.userScrolled() ||
!scroller ||
@@ -1630,7 +1604,7 @@ export default function Page() {
fillFrame = requestAnimationFrame(() => {
fillFrame = undefined
if (!params.id || !messagesReady()) return
if (!controller.identity.params.id || !messagesReady()) return
if (autoScroll.userScrolled() || historyLoading()) return
const el = scroller
@@ -1646,7 +1620,7 @@ export default function Page() {
on(
() =>
[
params.id,
controller.identity.params.id,
messagesReady(),
historyMore(),
historyLoading(),
@@ -1686,9 +1660,11 @@ export default function Page() {
})
}
const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
const roll = (
sessionID: string,
next: NonNullable<ReturnType<typeof controller.data.info>>["revert"],
target = sync(),
) => {
const session = target.session.get(sessionID)
if (!session) return
target.session.remember({ ...session, revert: next })
@@ -1697,20 +1673,20 @@ export default function Page() {
const busy = (sessionID: string) => sync().data.session_working(sessionID)
const queuedFollowups = createMemo(() => {
const id = params.id
const id = controller.identity.params.id
if (!id) return emptyFollowups
return followup.items[id] ?? emptyFollowups
})
const editingFollowup = createMemo(() => {
const id = params.id
const id = controller.identity.params.id
if (!id) return
return followup.edit[id]
})
const followupMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
const owner = sessionOwnership.capture()
const owner = controller.ownership.capture()
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
if (!item) return
@@ -1740,16 +1716,21 @@ export default function Page() {
followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
const sendingFollowup = createMemo(() => {
const id = params.id
const id = controller.identity.params.id
if (!id) return
if (!followupBusy(id)) return
return followupMutation.variables?.id
})
const queueEnabled = createMemo(() => {
const id = params.id
const id = controller.identity.params.id
if (!id) return false
return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession()
return (
settings.general.followup() === "queue" &&
controller.data.working() &&
!composer.blocked() &&
!controller.data.isChild()
)
})
const followupText = (item: FollowupDraft) => {
@@ -1790,7 +1771,7 @@ export default function Page() {
}
const editFollowup = (id: string) => {
const sessionID = params.id
const sessionID = controller.identity.params.id
if (!sessionID) return
if (followupBusy(sessionID)) return
@@ -1807,7 +1788,7 @@ export default function Page() {
}
const clearFollowupEdit = () => {
const id = params.id
const id = controller.identity.params.id
if (!id) return
setFollowup("edit", id, undefined)
}
@@ -1821,7 +1802,7 @@ export default function Page() {
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
const session = sdk().api.session
const api = sdk().api.session
const target = sync()
const last = target.session.get(input.sessionID)?.revert
const value = draft(input.messageID)
@@ -1831,7 +1812,7 @@ export default function Page() {
roll(input.sessionID, { messageID: input.messageID }, target)
prompt.set(value)
},
request: () => halt(input.sessionID).then(() => session.revert.stage(input)),
request: () => halt(input.sessionID).then(() => api.revert.stage(input)),
complete: () => undefined,
rollback: () => roll(input.sessionID, last, target),
fail,
@@ -1841,10 +1822,10 @@ export default function Page() {
const restoreMutation = useMutation(() => ({
mutationFn: async (id: string) => {
const sessionID = params.id
const sessionID = controller.identity.params.id
if (!sessionID) return
const session = sdk().api.session
const api = sdk().api.session
const target = sync()
const index = userMessages().findIndex((item) => item.id === id)
if (index < 0) return
@@ -1863,8 +1844,8 @@ export default function Page() {
},
request: () =>
!next
? halt(sessionID).then(() => session.revert.clear({ sessionID }))
: halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
? halt(sessionID).then(() => api.revert.clear({ sessionID }))
: halt(sessionID).then(() => api.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
complete: () => undefined,
rollback: () => roll(sessionID, last, target),
fail,
@@ -1881,12 +1862,12 @@ export default function Page() {
}
const restore = (id: string) => {
if (!params.id || reverting()) return
if (!controller.identity.params.id || reverting()) return
return restoreMutation.mutateAsync(id)
}
const rolled = createMemo(() => {
const id = revertMessageID()
const id = controller.data.revertMessageID()
if (!id) return []
const index = userMessages().findIndex((item) => item.id === id)
if (index < 0) return []
@@ -1921,7 +1902,7 @@ export default function Page() {
const actions = { revert, openAttachment }
createEffect(() => {
const sessionID = params.id
const sessionID = controller.identity.params.id
if (!sessionID) return
const item = queuedFollowups()[0]
@@ -1929,9 +1910,9 @@ export default function Page() {
if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return
if (isChildSession()) return
if (controller.data.isChild()) return
if (composer.blocked()) return
if (busy(sessionID)) return
if (controller.data.working()) return
void sendFollowup(sessionID, item.id)
})
@@ -1959,8 +1940,8 @@ export default function Page() {
)
const { clearMessageHash, scrollToMessage } = useSessionHashScroll({
sessionKey,
sessionID: () => params.id,
sessionKey: controller.identity.sessionKey,
sessionID: () => controller.identity.params.id,
messagesReady,
visibleUserMessages,
historyMore,
@@ -1986,7 +1967,7 @@ export default function Page() {
createEffect(
on(
() => params.id,
() => controller.identity.params.id,
(id) => {
if (!id) requestAnimationFrame(() => inputRef?.focus())
},
@@ -2049,19 +2030,23 @@ export default function Page() {
)
const sessionErrorFallback = (error: unknown, reset: () => void) => {
createEffect(on(sessionKey, reset, { defer: true }))
return <SessionErrorFallback error={error} sessionID={params.id} />
createEffect(on(controller.identity.sessionKey, reset, { defer: true }))
return <SessionErrorFallback error={error} sessionID={controller.identity.params.id} />
}
const sessionPanelContent = () => (
<>
{sessionSync() ?? ""}
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
<Show
when={
!isDesktop() && !!controller.identity.params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()
}
>
{mobileTabs(true)}
</Show>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id && mobileChanges()}>
<Match when={controller.identity.params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
@@ -2075,10 +2060,11 @@ export default function Page() {
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady() ? params.id : undefined} keyed>
<Match when={controller.identity.params.id}>
<Show when={messagesReady() ? controller.identity.params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
session={controller}
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
@@ -2123,25 +2109,25 @@ export default function Page() {
</Switch>
</div>
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>
<Show when={(controller.identity.params.id || !newSessionDesign()) && !mobileChanges()}>
{(_) => {
const controller = createSessionComposerRegionController({
const region = createSessionComposerRegionController({
state: composer,
sessionKey,
sessionID: () => params.id,
sessionKey: controller.identity.sessionKey,
sessionID: () => controller.identity.params.id,
prompt,
ready: () => !store.deferRender && messagesReady(),
centered,
todo: {
collapsed: () => view().todoCollapsed.get(),
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
collapsed: () => controller.layout.view().todoCollapsed.get(),
onToggle: () => controller.layout.view().todoCollapsed.set(!controller.layout.view().todoCollapsed.get()),
},
followup: () =>
params.id && !isChildSession()
controller.identity.params.id && !controller.data.isChild()
? {
items: followupDock(),
sending: sendingFollowup(),
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
onSend: (id) => void sendFollowup(controller.identity.params.id!, id, { manual: true }),
onEdit: editFollowup,
}
: undefined,
@@ -2156,11 +2142,11 @@ export default function Page() {
: undefined,
onResponseSubmit: resumeScroll,
openParent: () => {
const id = info()?.parentID
const id = controller.data.parentID()
if (!id) return
navigate(
params.serverKey
? sessionHref(requireServerKey(params.serverKey), id)
controller.identity.params.serverKey
? sessionHref(requireServerKey(controller.identity.params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
},
@@ -2173,7 +2159,7 @@ export default function Page() {
})
return (
<SessionComposerRegion
controller={controller}
controller={region}
promptInput={
<Show
when={newSessionDesign()}
@@ -2194,7 +2180,7 @@ export default function Page() {
shouldQueue={queueEnabled}
onQueue={queueFollowup}
onAbort={() => {
const id = params.id
const id = controller.identity.params.id
if (!id) return
setFollowup("paused", id, true)
}}
@@ -2202,7 +2188,7 @@ export default function Page() {
}
>
{(_) => {
const controller = usePromptInputV2Controller({
const promptInputController = usePromptInputV2Controller({
get controls() {
return inputController()
},
@@ -2224,12 +2210,12 @@ export default function Page() {
shouldQueue: queueEnabled,
onQueue: queueFollowup,
onAbort: () => {
const id = params.id
const id = controller.identity.params.id
if (!id) return
setFollowup("paused", id, true)
},
})
return <PromptInputV2Composer controller={controller} borderUnderlay />
return <PromptInputV2Composer controller={promptInputController} borderUnderlay />
}}
</Show>
}
@@ -2237,7 +2223,7 @@ export default function Page() {
)
}}
</Show>
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
<Show when={!!controller.identity.params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
</>
)
@@ -2251,7 +2237,9 @@ export default function Page() {
"gap-2 p-2": settings.general.newLayoutDesigns(),
}}
>
<Show when={!isDesktop() && !!params.id && !settings.general.newLayoutDesigns()}>{mobileTabs()}</Show>
<Show when={!isDesktop() && !!controller.identity.params.id && !settings.general.newLayoutDesigns()}>
{mobileTabs()}
</Show>
<div
classList={{
@@ -2266,13 +2254,13 @@ export default function Page() {
{settings.general.newLayoutDesigns() ? (
<Show when={sessionPanelKey()} keyed>
{(_) => (
<SessionPanelFrame newLayout raised={!!params.id}>
<SessionPanelFrame newLayout raised={!!controller.identity.params.id}>
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
</SessionPanelFrame>
)}
</Show>
) : (
<SessionPanelFrame newLayout={false} raised={!!params.id}>
<SessionPanelFrame newLayout={false} raised={!!controller.identity.params.id}>
{sessionPanelContent()}
</SessionPanelFrame>
)}
@@ -2359,7 +2347,7 @@ export default function Page() {
size.touch()
layout.terminal.resize(height)
}}
onCollapse={() => view().terminal.close()}
onCollapse={() => controller.layout.view().terminal.close()}
/>
</div>
</Show>
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test"
import type { AssistantMessage, Message, UserMessage } from "@/types"
import { createRoot, createSignal } from "solid-js"
import {
normalizeSessionTab,
normalizeSessionTabs,
selectSessionUserMessages,
selectVisibleSessionUserMessages,
} from "./session-domain"
import { createSessionOwnership } from "./session-ownership"
const user = (id: string): UserMessage => ({
id,
sessionID: "session",
role: "user",
time: { created: 0 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
})
const assistant: AssistantMessage = {
id: "msg_2",
sessionID: "session",
role: "assistant",
time: { created: 0 },
parentID: "msg_1",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/workspace", root: "/workspace" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
describe("session controller invariants", () => {
test("normalizes file tabs once while preserving non-file tabs and order", () => {
const normalize = (tab: string) => normalizeSessionTab(tab, (value) => value.toLowerCase())
expect(normalizeSessionTabs(["review", "file://SRC/A.TS", "file://src/a.ts", "context"], normalize)).toEqual([
"review",
"file://src/a.ts",
"context",
])
})
test("selects user history strictly before the revert boundary", () => {
const messages: Message[] = [user("msg_z"), assistant, user("msg_b"), user("msg_c")]
const users = selectSessionUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_z", "msg_b", "msg_c"])
expect(selectVisibleSessionUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_z"])
expect(selectVisibleSessionUserMessages(users)).toBe(users)
})
test("rejects work captured by a previous session", () => {
createRoot((dispose) => {
const [key, setKey] = createSignal("session-a")
const ownership = createSessionOwnership(key)
const captured = ownership.capture()
let ran = false
setKey("session-b")
expect(captured.current()).toBe(false)
expect(captured.run(() => (ran = true))).toBeUndefined()
expect(ran).toBe(false)
dispose()
})
})
})
@@ -0,0 +1,101 @@
import type { Message, UserMessage } from "@/types"
import { createMemo, type Accessor } from "solid-js"
import { useFile } from "@/context/file"
import { useSync } from "@/context/sync"
import { same } from "@/utils/same"
import { createSessionTabs } from "./helpers"
import {
normalizeSessionTab,
normalizeSessionTabs,
selectSessionUserMessages,
selectVisibleSessionUserMessages,
} from "./session-domain"
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
const idle = { type: "idle" as const }
export function createSessionController(input: {
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fileBrowser?: (sessionID: string | undefined) => boolean
}) {
const file = useFile()
const sync = useSync()
const layout = useSessionLayout()
const sessionID = createMemo(() => layout.params.id)
const info = createMemo(() => {
const id = sessionID()
return id ? sync().session.get(id) : undefined
})
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
return id ? sync().session.get(id) : undefined
})
const status = createMemo(() => {
const id = sessionID()
return id ? (sync().data.session_status[id] ?? idle) : idle
})
const messages = createMemo(() => {
const id = sessionID()
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
})
const userMessages = createMemo(() => selectSessionUserMessages(messages()), emptyUserMessages, { equals: same })
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const visibleUserMessages = createMemo(
() => selectVisibleSessionUserMessages(userMessages(), revertMessageID()),
emptyUserMessages,
{ equals: same },
)
const normalizeTab = (tab: string) => normalizeSessionTab(tab, file.tab)
const tabs = createSessionTabs({
tabs: layout.tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: input.review,
hasReview: input.hasReview,
fileBrowser: input.fileBrowser ? () => input.fileBrowser?.(sessionID()) ?? false : undefined,
})
return {
identity: {
params: layout.params,
sessionID,
sessionKey: layout.sessionKey,
workspaceKey: layout.workspaceKey,
},
data: {
info,
parent,
parentID,
isChild: createMemo(() => !!parentID()),
status,
working: createMemo(() => {
const id = sessionID()
return id ? sync().data.session_working(id) : false
}),
revertMessageID,
},
history: {
messages,
userMessages,
visibleUserMessages,
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
},
layout: {
tabs: layout.tabs,
view: layout.view,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
...tabs,
normalize: normalizeTab,
normalizeAll: (values: string[]) => normalizeSessionTabs(values, normalizeTab),
},
}
}
export type SessionController = ReturnType<typeof createSessionController>
@@ -0,0 +1,20 @@
import type { Message, UserMessage } from "@/types"
export function normalizeSessionTab(tab: string, normalizeFileTab: (tab: string) => string) {
if (!tab.startsWith("file://")) return tab
return normalizeFileTab(tab)
}
export function normalizeSessionTabs(tabs: string[], normalize: (tab: string) => string) {
return [...new Set(tabs.map(normalize))]
}
export function selectSessionUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
}
export function selectVisibleSessionUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
const boundary = messages.findIndex((message) => message.id === revertMessageID)
return boundary < 0 ? messages : messages.slice(0, boundary)
}
@@ -14,7 +14,7 @@ import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import { useSessionKey } from "@/pages/session/session-layout"
import type { SessionController } from "@/pages/session/session-controller"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { sessionTitle } from "@/utils/session-title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
@@ -24,8 +24,6 @@ import { createTimelineProjection } from "./projection"
const emptyMessages: Message[] = []
const emptyParts: Part[] = []
const idle = { type: "idle" as const }
const taskDescription = (part: Part, sessionID: string): string | undefined => {
if (part.type !== "tool" || part.tool !== "task") return undefined
const metadata = "metadata" in part.state ? part.state.metadata : undefined
@@ -35,7 +33,16 @@ const taskDescription = (part: Part, sessionID: string): string | undefined => {
return undefined
}
export function createTimelineController(input: { userMessages: Accessor<UserMessage[]> }) {
export type TimelineSessionSource = {
identity: Pick<SessionController["identity"], "params" | "sessionID" | "sessionKey">
data: Pick<SessionController["data"], "info" | "parent" | "parentID" | "status">
history: Pick<SessionController["history"], "messages">
}
export function createTimelineController(input: {
session: TimelineSessionSource
userMessages: Accessor<UserMessage[]>
}) {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
@@ -44,46 +51,33 @@ export function createTimelineController(input: { userMessages: Accessor<UserMes
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const { params, sessionKey } = useSessionKey()
const sessionID = createMemo(() => params.id)
const status = createMemo(() => {
const id = sessionID()
if (!id) return idle
return sync().data.session_status[id] ?? idle
})
const messages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
const projectedMessages = createMemo(() => {
const id = sessionID()
const id = input.session.identity.sessionID()
if (!id) return []
const visible = new Set(input.userMessages().map((message) => message.id))
const boundary = messages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const boundary = input.session.history
.messages()
.find((message) => message.role === "user" && !visible.has(message.id))?.id
const projected = sync().data.session_message[id] ?? []
if (!boundary) return projected
const index = projected.findIndex((message) => message.id === boundary)
return index < 0 ? projected : projected.slice(0, index)
})
const info = createMemo(() => {
const id = sessionID()
return id ? sync().session.get(id) : undefined
})
const titleValue = createMemo(() => info()?.title)
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = (): string | undefined => undefined
const shareEnabled = () => false
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
return id ? sync().session.get(id) : undefined
})
const parentMessages = createMemo(() => {
const id = parentID()
const id = input.session.data.parentID()
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const parentTitle = createMemo(
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("command.session.new"),
)
const parts = (messageID: string) => sync().data.part[messageID] ?? emptyParts
const part = (messageID: string, partID: string) => parts(messageID).find((item) => item.id === partID)
const childTaskDescription = createMemo(() => {
const id = sessionID()
const id = input.session.identity.sessionID()
if (!id) return undefined
return parentMessages()
.flatMap((message) => parts(message.id))
@@ -92,19 +86,19 @@ export function createTimelineController(input: { userMessages: Accessor<UserMes
})
const childTitle = createMemo(() => {
return timelineChildTitle({
parentID: parentID(),
parentID: input.session.data.parentID(),
taskDescription: childTaskDescription(),
title: titleLabel(),
fallback: language.t("command.session.new"),
})
})
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const showHeader = createMemo(() => !!(titleValue() || input.session.data.parentID()))
const projection = createTimelineProjection({
messages,
messages: input.session.history.messages,
userMessages: input.userMessages,
sessionMessages: projectedMessages,
parts,
status,
status: input.session.data.status,
showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
})
@@ -119,7 +113,7 @@ export function createTimelineController(input: { userMessages: Accessor<UserMes
return language.t("common.requestFailed")
}
const rename = async (title: string) => {
const id = sessionID()
const id = input.session.identity.sessionID()
if (!id || pending.rename) return false
const next = title.trim()
if (!next || next === (titleLabel() ?? "")) return true
@@ -142,22 +136,27 @@ export function createTimelineController(input: { userMessages: Accessor<UserMes
return true
}
const share = async () => {
const id = sessionID()
const id = input.session.identity.sessionID()
if (!id || pending.share || !shareEnabled()) return
}
const unshare = async () => {
const id = sessionID()
const id = input.session.identity.sessionID()
if (!id || pending.unshare || !shareEnabled()) return
}
const href = (id: string) =>
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
input.session.identity.params.serverKey
? sessionHref(requireServerKey(input.session.identity.params.serverKey), id)
: legacySessionHref(sdk().directory, id)
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
if (params.id !== id) return
if (input.session.identity.params.id !== id) return
if (parent) return navigate(href(parent))
if (next) return navigate(href(next))
if (params.serverKey)
return tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
navigate(`/${params.dir}/session`)
if (input.session.identity.params.serverKey)
return tabs.newDraft({
server: requireServerKey(input.session.identity.params.serverKey),
directory: sdk().directory,
})
navigate(`/${input.session.identity.params.dir}/session`)
}
const exportSession = async (id: string) => {
try {
@@ -250,7 +249,7 @@ export function createTimelineController(input: { userMessages: Accessor<UserMes
createEffect(
on(
() => [parentID(), childTaskDescription()] as const,
() => [input.session.data.parentID(), childTaskDescription()] as const,
([id, description]) => {
if (!id || description || sync().data.message[id] !== undefined) return
void sync().session.sync(id)
@@ -261,14 +260,14 @@ export function createTimelineController(input: { userMessages: Accessor<UserMes
return {
data: {
sessionKey,
sessionID,
status,
sessionKey: input.session.identity.sessionKey,
sessionID: input.session.identity.sessionID,
status: input.session.data.status,
titleValue,
titleLabel,
shareUrl,
shareEnabled,
parentID,
parentID: input.session.data.parentID,
parentTitle,
childTitle,
showHeader,
@@ -292,7 +291,7 @@ export function createTimelineController(input: { userMessages: Accessor<UserMes
export: exportSession,
showDelete: (id: string) => dialog.show(() => <DeleteDialog sessionID={id} />),
navigateParent: () => {
const id = parentID()
const id = input.session.data.parentID()
if (id) navigate(href(id))
},
viewShare: () => {
@@ -53,7 +53,7 @@ import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
import { createTimelineController, type TimelineController } from "./controller"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
const emptyTools: ToolPart[] = []
const emptyAssistantMessages: AssistantMessage[] = []
@@ -202,6 +202,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
}
type MessageTimelineProps = {
session: TimelineSessionSource
actions?: UserActions
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
onResumeScroll: () => void
@@ -224,7 +225,7 @@ type MessageTimelineProps = {
}
export function MessageTimeline(props: MessageTimelineProps) {
const controller = createTimelineController({ userMessages: () => props.userMessages })
const controller = createTimelineController({ session: props.session, userMessages: () => props.userMessages })
return (
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
)
@@ -1,23 +1,24 @@
import type { Message, UserMessage } from "@/types"
import type { Message } from "@/types"
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync"
import { same } from "@/utils/same"
import type { SessionController } from "../session-controller"
export {
selectSessionUserMessages as selectUserMessages,
selectVisibleSessionUserMessages as selectVisibleUserMessages,
} from "../session-domain"
const emptyUserMessages: UserMessage[] = []
const sessionFreshness = 15_000
export function createTimelineModel(input: {
sessionID: Accessor<string | undefined>
revertMessageID: Accessor<string | undefined>
}) {
export function createTimelineModel(input: { session: Pick<SessionController, "identity" | "history"> }) {
const serverSync = useServerSync()
const sync = useSync()
let refreshFrame: number | undefined
let refreshTimer: number | undefined
const [resource] = createResource(
() => input.sessionID(),
() => input.session.identity.sessionID(),
(id) => {
clearRefresh()
if (!id) return
@@ -29,7 +30,7 @@ export function createTimelineModel(input: {
refreshFrame = undefined
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined
if (input.sessionID() !== id) return
if (input.session.identity.sessionID() !== id) return
untrack(() => {
if (stale) void sync().session.sync(id, { force: true })
})
@@ -39,33 +40,21 @@ export function createTimelineModel(input: {
return sync().session.sync(id)
},
)
const messages = createMemo(() => {
const id = input.sessionID()
return id ? (sync().data.message[id] ?? []) : []
})
const ready = createMemo(() => {
const id = input.sessionID()
const id = input.session.identity.sessionID()
return !id || isTimelineReady(sync().data.message[id], serverSync().session.history.loading(id))
})
const userMessages = createMemo(() => selectUserMessages(messages()), emptyUserMessages, { equals: same })
const visibleUserMessages = createMemo(
() => {
return selectVisibleUserMessages(userMessages(), input.revertMessageID())
},
emptyUserMessages,
{ equals: same },
)
const more = createMemo(() => {
const id = input.sessionID()
const id = input.session.identity.sessionID()
return id ? sync().session.history.more(id) : false
})
const loading = createMemo(() => {
const id = input.sessionID()
const id = input.session.identity.sessionID()
return id ? sync().session.history.loading(id) : false
})
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
return loadOlderTimeline({
sessionID: input.sessionID,
sessionID: input.session.identity.sessionID,
more,
loading,
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
@@ -78,12 +67,12 @@ export function createTimelineModel(input: {
return {
history: { loadOlder, loading, more },
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
messages,
lastUserMessage: input.session.history.lastUserMessage,
messages: input.session.history.messages,
ready,
resource,
userMessages,
visibleUserMessages,
userMessages: input.session.history.userMessages,
visibleUserMessages: input.session.history.visibleUserMessages,
}
function clearRefresh() {
@@ -94,20 +83,10 @@ export function createTimelineModel(input: {
}
}
export function selectUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
}
export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
}
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
const boundary = messages.findIndex((message) => message.id === revertMessageID)
return boundary < 0 ? messages : messages.slice(0, boundary)
}
export async function loadOlderTimeline(input: {
sessionID: Accessor<string | undefined>
more: Accessor<boolean>
@@ -14,19 +14,25 @@ import { useTerminal } from "@/context/terminal"
import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { findLast } from "@opencode-ai/core/util/array"
import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt"
import type { UserMessage } from "@/types"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local"
import type { SessionController } from "./session-controller"
type SessionCommandSource = {
identity: SessionController["identity"]
data: Pick<SessionController["data"], "info" | "revertMessageID">
history: Pick<SessionController["history"], "userMessages" | "visibleUserMessages">
layout: SessionController["layout"]
ownership: SessionController["ownership"]
tabs: Pick<SessionController["tabs"], "activeFileTab" | "closableTab">
}
export type SessionCommandContext = {
session: SessionCommandSource
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
focusInput: () => void
review?: () => boolean
fileBrowser?: () => boolean
}
const withCategory = (category: string) => {
@@ -50,15 +56,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const layout = useLayout()
const local = useLocal()
const navigate = useNavigate()
const { params, sessionKey, tabs, view } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
const owner = sessionOwnership.capture()
const owner = actions.session.ownership.capture()
const value = await load()
owner.run(() => show(value))
}
const runCommand = async <T,>(input: {
owner: ReturnType<ReturnType<typeof createSessionOwnership>["capture"]>
owner: ReturnType<SessionController["ownership"]["capture"]>
prompt: T
request: () => Promise<unknown>
updatePrompt: (prompt: T) => void
@@ -69,41 +73,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
input.owner.run(input.updateViewport)
}
const info = () => {
const id = params.id
if (!id) return
return sync().session.get(id)
}
const hasReview = () => !!params.id
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: actions.review,
hasReview,
fileBrowser: actions.fileBrowser,
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const shown = settings.visibility.fileTree
const messages = () => {
const id = params.id
if (!id) return []
return sync().data.message[id] ?? []
}
const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[]
const visibleUserMessages = () => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
}
const showAllFiles = () => {
if (layout.fileTree.tab() !== "changes") return
layout.fileTree.setTab("all")
@@ -121,7 +92,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const canAddSelectionContext = () => {
const tab = activeFileTab()
const tab = actions.session.tabs.activeFileTab()
if (!tab) return false
const path = file.pathFromTab(tab)
if (!path) return false
@@ -141,7 +112,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const permissionsCommand = withCategory(language.t("command.category.permissions"))
const isAutoAcceptActive = () => {
const sessionID = params.id
const sessionID = actions.session.identity.params.id
if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory)
return permission.isAutoAcceptingDirectory(sdk().directory)
}
@@ -186,7 +157,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const share = async () => {
const sessionID = params.id
const sessionID = actions.session.identity.params.id
if (!sessionID) return
const existing = undefined
@@ -210,7 +181,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const unshare = async () => {
const sessionID = params.id
const sessionID = actions.session.identity.params.id
if (!sessionID) return
// TODO: Restore unsharing when the V2 client exposes a session sharing API.
@@ -222,7 +193,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const exportSession = async () => {
const sessionID = params.id
const sessionID = actions.session.identity.params.id
if (!sessionID) return
try {
const data = await fetchSessionExport({
@@ -254,13 +225,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const closeTab = () => {
const tab = closableTab()
const tab = actions.session.tabs.closableTab()
if (!tab) return
tabs().close(tab)
actions.session.layout.tabs().close(tab)
}
const addSelection = () => {
const tab = activeFileTab()
const tab = actions.session.tabs.activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
@@ -281,7 +252,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const openTerminal = () => {
if (terminal.all().length > 0) terminal.new({ focus: true })
if (terminal.all().length === 0) terminal.requestFocus()
view().terminal.open()
actions.session.layout.view().terminal.open()
}
const closeTerminal = () => {
@@ -289,7 +260,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!id) return
const last = terminal.all().length === 1
void terminal.close(id)
if (last) view().terminal.close()
if (last) actions.session.layout.view().terminal.close()
}
const chooseMcp = () => {
@@ -300,7 +271,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const toggleAutoAccept = () => {
const sessionID = params.id
const sessionID = actions.session.identity.params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory)
else permission.toggleAutoAcceptDirectory(sdk().directory)
@@ -318,14 +289,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const undo = async () => {
const sessionID = params.id
const sessionID = actions.session.identity.params.id
if (!sessionID) return
const owner = sessionOwnership.capture()
const owner = actions.session.ownership.capture()
const session = sdk().api.session
const directory = sdk().directory
const promptSession = prompt.capture()
const revert = info()?.revert?.messageID
const messages = userMessages()
const revert = actions.session.data.revertMessageID()
const messages = actions.session.history.userMessages()
const boundary = revert ? messages.findIndex((message) => message.id === revert) : messages.length
if (boundary < 0) return
const message = messages[boundary - 1]
@@ -348,14 +319,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const redo = async () => {
const sessionID = params.id
const sessionID = actions.session.identity.params.id
if (!sessionID) return
const owner = sessionOwnership.capture()
const owner = actions.session.ownership.capture()
const session = sdk().api.session
const messages = userMessages()
const messages = actions.session.history.userMessages()
const promptSession = prompt.capture()
const revertMessageID = info()?.revert?.messageID
const revertMessageID = actions.session.data.revertMessageID()
if (!revertMessageID) return
const boundary = messages.findIndex((message) => message.id === revertMessageID)
@@ -382,7 +353,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const compact = async () => {
const sessionID = params.id
const sessionID = actions.session.identity.params.id
if (!sessionID) return
await sdk().api.session.compact({ sessionID })
@@ -403,12 +374,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
return [
sessionCommand({
id: "session.share",
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
description: info()?.share?.url
title: actions.session.data.info()?.share?.url
? language.t("session.share.copy.copyLink")
: language.t("command.session.share"),
description: actions.session.data.info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
disabled: !actions.session.identity.params.id,
onSelect: share,
}),
sessionCommand({
@@ -416,7 +389,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
disabled: !actions.session.identity.params.id || !actions.session.data.info()?.share?.url,
onSelect: unshare,
}),
]
@@ -434,7 +407,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
command.trigger("tab.new", source)
return
}
navigate(`/${params.dir}/session`)
navigate(`/${actions.session.identity.params.dir}/session`)
},
}),
sessionCommand({
@@ -442,7 +415,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
onSelect: undo,
}),
sessionCommand({
@@ -450,7 +423,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
disabled: !actions.session.identity.params.id || !actions.session.data.info()?.revert?.messageID,
onSelect: redo,
}),
sessionCommand({
@@ -458,7 +431,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
onSelect: compact,
}),
sessionCommand({
@@ -466,7 +439,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
onSelect: fork,
}),
sessionCommand({
@@ -474,13 +447,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.export"),
description: language.t("command.session.export.description"),
slash: "export",
disabled: !params.id,
disabled: !actions.session.identity.params.id,
onSelect: exportSession,
}),
]
const fileCmds = () => {
const tab = closableTab()
const tab = actions.session.tabs.closableTab()
return [
fileCommand({
id: "file.open",
@@ -518,20 +491,20 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => {
if (view().terminal.opened()) {
if (actions.session.layout.view().terminal.opened()) {
terminal.cancelFocus()
view().terminal.close()
actions.session.layout.view().terminal.close()
return
}
terminal.requestFocus(terminal.active())
view().terminal.open()
actions.session.layout.view().terminal.open()
},
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
onSelect: () => actions.session.layout.view().reviewPanel.toggle(),
}),
...(shown()
? [
@@ -575,7 +548,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+alt+[",
disabled: !params.id,
disabled: !actions.session.identity.params.id,
onSelect: () => navigateMessageByOffset(-1),
}),
sessionCommand({
@@ -583,7 +556,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+alt+]",
disabled: !params.id,
disabled: !actions.session.identity.params.id,
onSelect: () => navigateMessageByOffset(1),
}),
]
+29
View File
@@ -0,0 +1,29 @@
import { $ } from "bun"
import { readdir } from "node:fs/promises"
import path from "node:path"
export type AppAsset = {
readonly key: string
readonly source: string
}
export async function buildAppAssets(channel: string) {
const root = path.resolve(import.meta.dirname, "../../app")
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
return (await files(path.join(root, "dist")))
.filter((key) => !key.endsWith(".map"))
.map((key): AppAsset => ({ key, source: path.join(root, "dist", key) }))
}
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
}),
)
)
.flat()
.toSorted()
}
+15 -2
View File
@@ -12,6 +12,7 @@ import { modelsData } from "./generate"
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
import { mainConfig } from "../vite.node.config"
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
import { buildAppAssets } from "./app-assets"
const NODE_VERSION = "26.4.0"
const dir = path.resolve(import.meta.dirname, "..")
@@ -26,6 +27,7 @@ if (outdir === path.join(dir, "dist-node")) {
const bundleOnly = process.argv.includes("--bundle-only")
const single = process.argv.includes("--single")
const skipInstall = process.argv.includes("--skip-install")
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
const requested = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
const allTargets = [
nodeTarget("linux", "arm64"),
@@ -55,13 +57,24 @@ const builder =
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
? await resolveHostNode()
: undefined
const appAssets = skipEmbedWebUi ? [] : await buildAppAssets(Script.channel)
for (const target of targets) {
console.log(`building cli-node-${targetName(target)}`)
const assets = await collectNodeAssets(target)
const assets = [
...(await collectNodeAssets(target)),
...appAssets.map((asset) => ({ key: `app/${asset.key}`, source: asset.source })),
]
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
const input = {
version: Script.version,
channel: Script.channel,
models: modelsData,
assetHash,
target,
appAssets: appAssets.map((asset) => asset.key),
}
await copyNodeAssets(assets)
await build(mainConfig(input))
+20 -1
View File
@@ -8,6 +8,7 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { modelsData } from "./generate"
import { buildAppAssets } from "./app-assets"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -23,6 +24,7 @@ await rm(outdir, { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
const solidPlugin = createSolidTransformPlugin()
const allTargets: {
@@ -54,6 +56,23 @@ const targets = singleFlag
: allTargets
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
const appAssets = skipEmbedWebUi ? [] : await buildAppAssets(Script.channel)
const appAssetsPlugin: BunPlugin = {
name: "opencode-app-assets",
setup(build) {
build.onResolve({ filter: /^virtual:opencode-app-assets$/ }, () => ({
path: "opencode-app-assets",
namespace: "opencode",
}))
build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({
loader: "js",
contents: `${appAssets
.map((asset, index) => `import asset_${index} from ${JSON.stringify(asset.source)} with { type: "file" }`)
.join("\n")}
export default {${appAssets.map((asset, index) => `${JSON.stringify(asset.key)}: asset_${index}`).join(",")}}`,
}))
},
}
for (const item of targets) {
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
@@ -80,7 +99,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin, parcelWatcherPlugin],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+32
View File
@@ -0,0 +1,32 @@
import { readdir } from "node:fs/promises"
import path from "node:path"
export type AssetMap = Readonly<Record<string, string>>
let result: Promise<AssetMap> | undefined
export function load() {
return (result ??= import("virtual:opencode-app-assets")
.then((module) => module.default)
.catch(() => ({}))
.then((assets) => (Object.keys(assets).length > 0 ? assets : sourceAssets())))
}
async function sourceAssets(): Promise<AssetMap> {
const root = path.resolve(import.meta.dirname, "../../app/dist")
const entries = await files(root).catch(() => [])
return Object.fromEntries(entries.filter((file) => !file.endsWith(".map")).map((file) => [file, path.join(root, file)]))
}
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
}),
)
)
.flat()
.toSorted()
}
+8 -1
View File
@@ -266,8 +266,15 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
],
}),
Spec.make("pair", { description: "Show server pairing information" }),
Spec.make("web", {
description: "Start the server and open the web interface",
params: {
hostname: Flag.string("hostname").pipe(Flag.optional),
port: Flag.integer("port").pipe(Flag.optional),
},
}),
Spec.make("serve", {
description: "Start the v2 API server",
description: "Start the v2 API and web server",
params: {
hostname: Flag.string("hostname").pipe(Flag.optional),
port: Flag.integer("port").pipe(Flag.optional),
+11 -3
View File
@@ -4,12 +4,13 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Config } from "../../config"
import { Context, Effect, FileSystem, Option } from "effect"
import { Context, Effect, FileSystem, Option, Ref, Scope } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/util/npm"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { WebUi } from "../../services/web-ui"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
@@ -37,11 +38,13 @@ export default Runtime.handler(Commands, (input) =>
),
)
preflight.loading()
const endpoint = yield* Ref.make(server.endpoint)
const web = yield* Effect.cached(WebUi.start(endpoint))
const config = yield* Config.Service
const npm = yield* Npm.Service
const fileSystem = yield* FileSystem.FileSystem
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
const context = yield* Effect.context<FileSystem.FileSystem>()
const context = yield* Effect.context<FileSystem.FileSystem | Scope.Scope>()
const runFork = Effect.runForkWith(context)
const runPromise = Effect.runPromiseWith(context)
const service = server.service
@@ -55,11 +58,16 @@ export default Runtime.handler(Commands, (input) =>
endpoint: server.endpoint,
service: service
? {
reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
reconnect: (signal) =>
runServicePromise(
service.reconnect().pipe(Effect.tap((next) => Ref.set(endpoint, next))),
{ signal },
),
restart: () => runServicePromise(service.restart()),
}
: undefined,
},
web: () => runPromise(web),
args: {
continue: input.continue,
sessionID: Option.getOrUndefined(input.session),
+15
View File
@@ -0,0 +1,15 @@
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerProcess } from "../../server-process"
export default Runtime.handler(
Commands.commands.web,
Effect.fnUntraced(function* (input) {
return yield* ServerProcess.run({
mode: "web",
hostname: Option.getOrUndefined(input.hostname),
port: Option.getOrUndefined(input.port),
})
}),
)
+1
View File
@@ -52,6 +52,7 @@ const Handlers = Runtime.handlers(Commands, {
unset: () => import("./commands/handlers/service/unset"),
},
serve: () => import("./commands/handlers/serve"),
web: () => import("./commands/handlers/web"),
})
Effect.logInfo("cli starting", {
+25 -7
View File
@@ -1,20 +1,22 @@
export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { Service, type DiscoverOptions, type Endpoint, type Info } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect"
import { Effect, FileSystem, Option, Redacted, Ref, Schedule, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
import open from "open"
export type Mode = "default" | "service" | "stdio"
export type Mode = "default" | "service" | "stdio" | "web"
export type Options = {
readonly mode: Mode
@@ -42,6 +44,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
return yield* Effect.scoped(
Effect.gen(function* () {
const foreground = options.mode === "default" || options.mode === "web"
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
@@ -74,8 +77,8 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
},
hostname,
port,
hostname: foreground ? "127.0.0.1" : hostname,
port: foreground ? 0 : port,
password,
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
@@ -140,9 +143,24 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
}),
)
if (server === undefined) return
const url = HttpServer.formatAddress(server.address)
const url =
foreground
? yield* WebUi.serve(
yield* Ref.make<Endpoint>({
url: HttpServer.formatAddress(server.address),
auth: { type: "basic", username: "opencode", password },
}),
{ hostname, port, password },
)
: HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
if (options.mode === "web") {
const target = new URL(url)
if (target.hostname === "0.0.0.0" || target.hostname === "::") target.hostname = "localhost"
target.searchParams.set("auth_token", Buffer.from(`opencode:${password}`).toString("base64"))
yield* Effect.promise(() => open(target.toString()).catch(() => undefined))
}
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* options.mode === "service"
+350
View File
@@ -0,0 +1,350 @@
import { NodeHttpServer, NodeSocket } from "@effect/platform-node"
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { ServerInfo } from "@opencode-ai/server/server-info"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Context, Effect, Exit, Ref, Scope, Stream } from "effect"
import {
FetchHttpClient,
HttpBody,
HttpClient,
HttpClientRequest,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import { Socket } from "effect/unstable/socket"
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
import { readFile } from "node:fs/promises"
import { createServer } from "node:http"
import { load } from "../app-assets"
const UI_UPSTREAM = new URL("https://app.opencode.ai")
const COOKIE = "opencode-web"
const hop = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"host",
])
export const start = Effect.fn("cli.web-ui.start")(function* (
endpoint: Ref.Ref<Endpoint>,
options?: { readonly assets?: Readonly<Record<string, string>> },
) {
const token = randomBytes(32).toString("base64url")
const origin = yield* listen(endpoint, {
auth: { type: "cookie", token },
hostname: "127.0.0.1",
port: 0,
assets: options?.assets,
})
return `${origin}/?cli_token=${encodeURIComponent(token)}`
})
export const serve = Effect.fn("cli.web-ui.serve")(function* (
endpoint: Ref.Ref<Endpoint>,
options: {
readonly hostname: string
readonly port?: number
readonly password: string
readonly assets?: Readonly<Record<string, string>>
},
) {
return yield* listen(endpoint, {
auth: { type: "basic", password: options.password },
hostname: options.hostname,
port: options.port,
assets: options.assets,
})
})
const listen = Effect.fnUntraced(function* (
endpoint: Ref.Ref<Endpoint>,
options: {
readonly auth: { readonly type: "cookie"; readonly token: string } | { readonly type: "basic"; readonly password: string }
readonly hostname: string
readonly port?: number
readonly assets?: Readonly<Record<string, string>>
},
) {
const assets = options.assets ?? (yield* Effect.promise(load))
const client = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer))
const websocket = yield* Socket.WebSocketConstructor.pipe(Effect.provide(NodeSocket.layerWebSocketConstructorWS))
const server = yield* bind(options.hostname, options.port)
const origin = formatAddress(server.http.address)
const urls = ServerInfo.connectionURLs(origin, options.hostname)
yield* server.http.serve(
handle({ endpoint, auth: options.auth, assets, client, websocket, origin, urls }),
).pipe(Effect.provideService(Scope.Scope, server.scope))
return origin
})
function handle(input: {
readonly endpoint: Ref.Ref<Endpoint>
readonly auth: { readonly type: "cookie"; readonly token: string } | { readonly type: "basic"; readonly password: string }
readonly assets: Readonly<Record<string, string>>
readonly client: HttpClient.HttpClient
readonly websocket: Context.Service.Shape<typeof Socket.WebSocketConstructor>
readonly origin: string
readonly urls: ReadonlyArray<string>
}) {
return Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, input.origin)
if (input.auth.type === "cookie" && request.headers.host !== new URL(input.origin).host)
return HttpServerResponse.empty({ status: 403 })
const queryToken = url.searchParams.get(input.auth.type === "cookie" ? "cli_token" : "auth_token")
const queryAuthorized = input.auth.type === "cookie" && matches(queryToken, input.auth.token)
if (input.auth.type === "cookie" && queryToken !== null && request.headers.upgrade?.toLowerCase() !== "websocket") {
if (!queryAuthorized) return HttpServerResponse.empty({ status: 401 })
url.searchParams.delete("cli_token")
return HttpServerResponse.empty({
status: 302,
headers: {
location: url.pathname + url.search + url.hash,
"set-cookie": `${COOKIE}=${input.auth.token}; HttpOnly; SameSite=Strict; Path=/`,
"cache-control": "no-store",
},
})
}
if (input.auth.type === "cookie" && !queryAuthorized && !authorized(request.headers.cookie, input.auth.token))
return unauthorized(false)
if (
input.auth.type === "basic" &&
!hasPtyTicket(url) &&
!basicAuthorized(request.headers.authorization, queryToken, input.auth.password)
)
return unauthorized(true)
url.searchParams.delete("cli_token")
url.searchParams.delete("auth_token")
const requestOrigin = request.headers.host ? `http://${request.headers.host}` : input.origin
if (request.headers.origin !== undefined && request.headers.origin !== requestOrigin)
return HttpServerResponse.empty({ status: 403 })
if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
const endpoint = yield* Ref.get(input.endpoint)
const target = new URL(url.pathname + url.search, endpoint.url)
if (request.headers.upgrade?.toLowerCase() === "websocket")
return yield* proxyWebSocket(request, target, input.websocket)
return yield* proxyHttp(input.client, request, target, Service.headers(endpoint), false, input.urls)
}
return yield* serveUI(input.client, request, url, input.assets)
})
}
function serveUI(
client: HttpClient.HttpClient,
request: HttpServerRequest.HttpServerRequest,
url: URL,
assets: Readonly<Record<string, string>>,
) {
const key = url.pathname.replace(/^\//, "")
const file = assets[key] ?? assets["index.html"]
if (!file) return proxyHttp(client, request, new URL(url.pathname + url.search, UI_UPSTREAM), undefined, true)
if (request.method !== "GET" && request.method !== "HEAD") return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
return Effect.tryPromise(() => readFile(file)).pipe(
Effect.map((body) => {
const html = key === "" || file === assets["index.html"]
const headers = {
"content-type": FSUtil.mimeType(file),
"cache-control": html ? "no-cache" : "public, max-age=31536000, immutable",
"content-security-policy": html ? cspForHtml(body.toString()) : csp(),
"x-content-type-options": "nosniff",
}
if (request.method === "HEAD") return HttpServerResponse.empty({ headers })
return HttpServerResponse.raw(body, { headers })
}),
Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
)
}
function proxyHttp(
client: HttpClient.HttpClient,
request: HttpServerRequest.HttpServerRequest,
target: URL,
extra: HeadersInit | undefined,
ui = false,
publicURLs?: ReadonlyArray<string>,
) {
return client
.execute(
HttpClientRequest.make(request.method as never)(target, {
headers: proxyHeaders(request.headers, extra),
body: requestBody(request),
}),
)
.pipe(
Effect.flatMap((response) => {
const headers = new Headers(response.headers)
headers.delete("content-encoding")
headers.delete("content-length")
headers.delete("set-cookie")
if (publicURLs && target.pathname === "/api/server")
return Effect.succeed(HttpServerResponse.jsonUnsafe({ urls: publicURLs }, { status: response.status }))
if (ui && response.headers["content-type"]?.includes("text/html")) {
return response.text.pipe(
Effect.map((body) => {
headers.set("content-security-policy", cspForHtml(body))
headers.set("cache-control", "no-store")
return HttpServerResponse.text(body, { status: response.status, headers })
}),
)
}
if (ui) headers.set("content-security-policy", csp())
return Effect.succeed(
HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
status: response.status,
headers,
}),
)
}),
Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 502 }))),
)
}
function proxyWebSocket(
request: HttpServerRequest.HttpServerRequest,
target: URL,
websocket: Context.Service.Shape<typeof Socket.WebSocketConstructor>,
) {
target.protocol = target.protocol === "https:" ? "wss:" : "ws:"
return Effect.scoped(
Effect.gen(function* () {
const inbound = yield* Effect.orDie(request.upgrade)
const outbound = yield* Socket.makeWebSocket(target.toString(), {
protocols: protocols(request.headers["sec-websocket-protocol"]),
closeCodeIsError: () => false,
}).pipe(Effect.provideService(Socket.WebSocketConstructor, websocket))
const writeInbound = yield* inbound.writer
const writeOutbound = yield* outbound.writer
const close = Effect.all(
[writeInbound(new Socket.CloseEvent()), writeOutbound(new Socket.CloseEvent())],
{ concurrency: "unbounded", discard: true },
).pipe(Effect.timeout("1 second"), Effect.catch(() => Effect.void))
yield* Effect.raceFirst(
outbound.runRaw((message) => writeInbound(typeof message === "string" ? message : message.slice())),
inbound.runRaw((message) => writeOutbound(typeof message === "string" ? message : message.slice())),
).pipe(Effect.catch(() => Effect.void), Effect.ensuring(close))
return HttpServerResponse.empty()
}).pipe(Effect.orDie),
)
}
function requestBody(request: HttpServerRequest.HttpServerRequest) {
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
if (request.source instanceof Request && request.source.body === null) return HttpBody.empty
const length = request.headers["content-length"]
return HttpBody.stream(request.stream, request.headers["content-type"], length ? Number(length) : undefined)
}
function proxyHeaders(input: Record<string, string>, extra?: HeadersInit) {
const headers = new Headers(input)
for (const key of input.connection?.split(",").map((item) => item.trim()) ?? []) headers.delete(key)
for (const key of hop) headers.delete(key)
headers.delete("accept-encoding")
headers.delete("authorization")
headers.delete("cookie")
if (extra) for (const [key, value] of new Headers(extra)) headers.set(key, value)
return headers
}
function authorized(cookie: string | undefined, token: string) {
const value = cookie
?.split(";")
.map((item) => item.trim().split("="))
.find(([key]) => key === COOKIE)?.[1]
return matches(value ?? null, token)
}
function basicAuthorized(header: string | undefined, queryToken: string | null, password: string) {
const expected = Buffer.from(`opencode:${password}`).toString("base64")
if (matches(queryToken, expected)) return true
if (!header?.startsWith("Basic ")) return false
return matches(header.slice("Basic ".length), expected)
}
function hasPtyTicket(url: URL) {
return /^\/api\/pty\/[^/]+\/connect$/.test(url.pathname) && !!url.searchParams.get("ticket")
}
function unauthorized(basic: boolean) {
return HttpServerResponse.empty({
status: 401,
headers: basic ? { "www-authenticate": 'Basic realm="Secure Area"' } : undefined,
})
}
function matches(value: string | null, expected: string) {
if (value === null) return false
const left = Buffer.from(value)
const right = Buffer.from(expected)
return left.length === right.length && timingSafeEqual(left, right)
}
function protocols(value: string | undefined) {
return value
?.split(",")
.map((item) => item.trim())
.filter(Boolean)
}
function csp(hash = "") {
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data: blob:`
}
function cspForHtml(body: string) {
const match = body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(["'])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")
}
function bind(hostname: string, port: number | undefined) {
if (port !== undefined) return bindPort(hostname, port)
const next = (candidate: number): ReturnType<typeof bindPort> =>
bindPort(hostname, candidate).pipe(
Effect.catch((error) =>
candidate < 65_535 && addressInUse(error) ? next(candidate + 1) : Effect.fail(error),
),
)
return next(4096)
}
function bindPort(hostname: string, port: number) {
return Effect.gen(function* () {
const sockets = new Set<{ destroy(): void }>()
const server = createServer()
const scope = yield* Scope.make()
server.on("connection", (socket) => {
sockets.add(socket)
socket.once("close", () => sockets.delete(socket))
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => sockets.forEach((socket) => socket.destroy())).pipe(
Effect.andThen(Scope.close(scope, Exit.void)),
),
)
const http = yield* NodeHttpServer.make(() => server, { host: hostname, port }).pipe(
Effect.provideService(Scope.Scope, scope),
)
return { http, scope }
})
}
function formatAddress(address: HttpServer.Address) {
if (address._tag === "UnixAddress") return HttpServer.formatAddress(address)
const hostname = address.hostname.includes(":") ? `[${address.hostname}]` : address.hostname
return `http://${hostname}:${address.port}`
}
function addressInUse(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false
if ("code" in error && error.code === "EADDRINUSE") return true
return "cause" in error && addressInUse(error.cause)
}
export * as WebUi from "./web-ui"
+4
View File
@@ -0,0 +1,4 @@
declare module "virtual:opencode-app-assets" {
const assets: Readonly<Record<string, string>>
export default assets
}
+240
View File
@@ -0,0 +1,240 @@
import { afterAll, describe, expect, test } from "bun:test"
import { WebUi } from "../src/services/web-ui"
import type { Endpoint } from "@opencode-ai/client/effect/service"
import { Effect, Ref } from "effect"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
afterAll(() => rm(root, { recursive: true, force: true }))
describe("TUI web UI", () => {
test("bootstraps a private browser session and proxies the current API endpoint", async () => {
const index = path.join(root, "index.html")
await writeFile(index, "<html><body>embedded</body></html>")
const first = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: () => Response.json({ server: "first" }),
})
const second = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: () => Response.json({ server: "second" }),
})
try {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Ref.make({ url: first.url.toString() })
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
const origin = new URL(launch).origin
expect((yield* Effect.promise(() => fetch(origin))).status).toBe(401)
const bootstrap = yield* Effect.promise(() => fetch(launch, { redirect: "manual" }))
expect(bootstrap.status).toBe(302)
expect(bootstrap.headers.get("location")).toBe("/")
const cookie = bootstrap.headers.get("set-cookie")?.split(";", 1)[0]
expect(cookie).toStartWith("opencode-web=")
const page = yield* Effect.promise(() => fetch(origin, { headers: { cookie: cookie ?? "" } }))
expect(yield* Effect.promise(() => page.text())).toContain("embedded")
expect(page.headers.get("content-security-policy")).toContain("default-src 'self'")
const before = yield* Effect.promise(() => fetch(`${origin}/api/health`, { headers: { cookie: cookie ?? "" } }))
expect(yield* Effect.promise(() => before.json())).toEqual({ server: "first" })
yield* Ref.set(endpoint, { url: second.url.toString() })
const after = yield* Effect.promise(() => fetch(`${origin}/api/health`, { headers: { cookie: cookie ?? "" } }))
expect(yield* Effect.promise(() => after.json())).toEqual({ server: "second" })
}),
),
)
} finally {
first.stop(true)
second.stop(true)
}
})
test("rejects foreign origins", async () => {
const index = path.join(root, "origin.html")
await writeFile(index, "embedded")
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Ref.make({ url: "http://127.0.0.1:1" })
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
const bootstrap = yield* Effect.promise(() => fetch(launch, { redirect: "manual" }))
const cookie = bootstrap.headers.get("set-cookie")?.split(";", 1)[0]
const response = yield* Effect.promise(() =>
fetch(new URL(launch).origin, {
headers: { cookie: cookie ?? "", origin: "https://example.com" },
}),
)
expect(response.status).toBe(403)
}),
),
)
})
test("forwards websocket messages", async () => {
const index = path.join(root, "websocket.html")
await writeFile(index, "embedded")
const upstream = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request, server) {
if (server.upgrade(request)) return
return new Response(null, { status: 426 })
},
websocket: {
message(socket, message) {
socket.send(message)
},
},
})
try {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Ref.make({ url: upstream.url.toString() })
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
const target = new URL("/api/pty/test/connect?ticket=test", launch)
target.searchParams.set("cli_token", new URL(launch).searchParams.get("cli_token") ?? "")
target.protocol = "ws:"
const message = yield* Effect.promise(
() =>
new Promise<string>((resolve, reject) => {
const socket = new WebSocket(target)
socket.addEventListener("open", () => socket.send("hello"), { once: true })
socket.addEventListener("message", (event) => {
resolve(event.data.toString())
socket.close()
}, { once: true })
socket.addEventListener("error", reject, { once: true })
}),
)
expect(message).toBe("hello")
}),
),
)
} finally {
upstream.stop(true)
}
})
test("serves foreground UI with server credentials", async () => {
const index = path.join(root, "serve.html")
await writeFile(index, "<html>foreground</html>")
const upstream = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: (request) =>
new URL(request.url).pathname === "/api/server"
? Response.json({ urls: ["http://private"] })
: Response.json({ url: request.url }),
})
try {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Ref.make<Endpoint>({
url: upstream.url.toString(),
auth: { type: "basic", username: "opencode", password: "private" },
})
const origin = yield* WebUi.serve(endpoint, {
hostname: "127.0.0.1",
port: 0,
password: "secret",
assets: { "index.html": index },
})
const denied = yield* Effect.promise(() => fetch(origin))
expect(denied.status).toBe(401)
expect(denied.headers.get("www-authenticate")).toContain("Basic")
const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`
const page = yield* Effect.promise(() => fetch(origin, { headers: { authorization } }))
expect(yield* Effect.promise(() => page.text())).toContain("foreground")
const token = Buffer.from("opencode:secret").toString("base64")
const query = yield* Effect.promise(() => fetch(`${origin}/?auth_token=${encodeURIComponent(token)}`))
expect(query.status).toBe(200)
const proxied = yield* Effect.promise(() =>
fetch(`${origin}/api/health?auth_token=${encodeURIComponent(token)}&keep=yes`),
)
const proxiedBody = yield* Effect.promise(() => proxied.json())
expect(new URL(proxiedBody.url).search).toBe("?keep=yes")
const info = yield* Effect.promise(() => fetch(`${origin}/api/server`, { headers: { authorization } }))
expect(yield* Effect.promise(() => info.json())).toEqual({ urls: [origin] })
}),
),
)
} finally {
upstream.stop(true)
}
})
test("formats localhost listeners as valid URLs", async () => {
const index = path.join(root, "localhost.html")
await writeFile(index, "embedded")
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Ref.make({ url: "http://127.0.0.1:1" })
const origin = yield* WebUi.serve(endpoint, {
hostname: "localhost",
port: 0,
password: "secret",
assets: { "index.html": index },
})
expect(new URL(origin).protocol).toBe("http:")
}),
),
)
})
test("shuts down with an active websocket", async () => {
const index = path.join(root, "shutdown.html")
await writeFile(index, "embedded")
const upstream = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request, server) {
if (server.upgrade(request)) return
return new Response(null, { status: 426 })
},
websocket: { message() {} },
})
let socket: WebSocket | undefined
try {
const run = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Ref.make({ url: upstream.url.toString() })
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
const target = new URL("/api/pty/test/connect?ticket=test", launch)
target.searchParams.set("cli_token", new URL(launch).searchParams.get("cli_token") ?? "")
target.protocol = "ws:"
socket = new WebSocket(target)
yield* Effect.promise(
() => new Promise<void>((resolve, reject) => {
socket?.addEventListener("open", () => resolve(), { once: true })
socket?.addEventListener("error", reject, { once: true })
}),
)
}),
),
)
await Promise.race([
run,
new Promise((_, reject) => setTimeout(() => reject(new Error("web UI shutdown timed out")), 2_000)),
])
} finally {
socket?.close()
upstream.stop(true)
}
})
})
+20
View File
@@ -17,6 +17,23 @@ function rawTextPlugin(): Plugin {
}
}
function appAssetsPlugin(assets: readonly string[]): Plugin {
return {
name: "opencode:app-assets",
resolveId(id) {
if (id === "virtual:opencode-app-assets") return "\0virtual:opencode-app-assets"
},
load(id) {
if (id !== "\0virtual:opencode-app-assets") return
return `import path from "node:path"
const root = process.env.OPENCODE_NODE_ASSETS_DIR
export default root ? {${assets
.map((key) => `${JSON.stringify(key)}: path.join(root, "app", ${JSON.stringify(key)})`)
.join(",")}} : {}`
},
}
}
function runtimeRequirePlugin(): Plugin {
return {
name: "opencode:runtime-require",
@@ -212,12 +229,14 @@ export type NodeBuildInput = {
readonly models: string
readonly assetHash: string
readonly target: NodeTarget
readonly appAssets: readonly string[]
}
export function mainConfig(input: NodeBuildInput): UserConfig {
return defineConfig({
root: dir,
plugins: [
appAssetsPlugin(input.appAssets),
rawTextPlugin(),
runtimeRequirePlugin(),
fffNodePlugin(),
@@ -259,4 +278,5 @@ export default mainConfig({
models: "undefined",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
appAssets: [],
})
+30 -1
View File
@@ -179,6 +179,7 @@ export type TuiInput = {
args: Args
config: Config.Interface
packages: PackageResolver
web?: () => Promise<string>
terminalHandoff?: () => Promise<
| {
readonly renderer: CliRenderer
@@ -375,6 +376,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
directories={pluginDirectories}
>
<App
web={input.web}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
@@ -434,7 +436,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App(props: { pair?: DialogPairCredentials }) {
function App(props: { pair?: DialogPairCredentials; web?: () => Promise<string> }) {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
@@ -952,6 +954,33 @@ function App(props: { pair?: DialogPairCredentials }) {
},
category: "System",
},
...(props.web
? [
{
name: "web.open",
title: "Open web interface",
slash: { name: "web" },
run: async () => {
const web = props.web
if (!web) return
const url = await web().catch((error) => {
toast.error(error)
return undefined
})
if (!url) return
await open(url).catch(() =>
toast.show({
title: "Could not open browser",
message: `Open ${url} manually.`,
variant: "warning",
}),
)
dialog.clear()
},
category: "System",
},
]
: []),
{
name: "app.exit",
title: "Exit the app",