mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 11:39:45 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c34e6669f | |||
| c7852ef0fd |
+129
-141
@@ -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),
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -907,7 +907,7 @@ export type Endpoint5_31Output =
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
|
||||
|
||||
export type Endpoint5_32Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
|
||||
export type Endpoint5_32Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
|
||||
|
||||
|
||||
@@ -596,7 +596,10 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
|
||||
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveEffect<Endpoint5_32Output>()(
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.interrupt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { continue: input["continue"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
|
||||
@@ -875,6 +875,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`,
|
||||
query: { continue: input["continue"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
|
||||
@@ -3888,7 +3888,10 @@ export type SessionLogInput = {
|
||||
|
||||
export type SessionLogOutput = SessionLogItem
|
||||
|
||||
export type SessionInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
export type SessionInterruptInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly continue?: { readonly continue?: boolean | undefined }["continue"]
|
||||
}
|
||||
|
||||
export type SessionInterruptOutput = void
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const log = yield* client.session
|
||||
.log({ sessionID: Session.ID.make("ses_test"), after: Event.Seq.make(0) })
|
||||
.pipe(Stream.runCollect)
|
||||
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
|
||||
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test"), continue: true })
|
||||
const message = yield* client.session.message({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_model"),
|
||||
|
||||
@@ -543,7 +543,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
const context = await client.session.context({ sessionID: "ses_test" })
|
||||
const log = []
|
||||
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
|
||||
await client.session.interrupt({ sessionID: "ses_test" })
|
||||
await client.session.interrupt({ sessionID: "ses_test", continue: true })
|
||||
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
@@ -568,7 +568,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
||||
["GET", "http://localhost:3000/api/experimental/session/ses_test/log?after=0"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt?continue=true"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||
])
|
||||
const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body
|
||||
|
||||
@@ -267,7 +267,7 @@ export interface Interface {
|
||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { continue?: boolean }) => Effect.Effect<void>
|
||||
readonly synthetic: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -848,7 +848,9 @@ const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
),
|
||||
interrupt: Effect.fn("Session.interrupt")((sessionID) => Effect.uninterruptible(execution.interrupt(sessionID))),
|
||||
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID, options)),
|
||||
),
|
||||
revert: {
|
||||
stage: Effect.fn("Session.revert.stage")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface Interface {
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { continue?: boolean }) => Effect.Effect<void>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
active: coordinator.active,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
|
||||
interrupt: (sessionID, options) => coordinator.interrupt(sessionID, "user", { preserveWake: options?.continue }),
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
|
||||
@@ -10,8 +10,8 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
||||
/** Stops the active execution and waits for cleanup. Clears its doorbell unless preservation is requested. */
|
||||
readonly interrupt: (key: Key, reason?: Reason, options?: { preserveWake?: boolean }) => Effect.Effect<void>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
@@ -124,12 +124,12 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
start(key, false)
|
||||
})
|
||||
|
||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||
const interrupt = (key: Key, reason?: Reason, options?: { preserveWake?: boolean }): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution?.owner === undefined || execution.stopping) return Effect.void
|
||||
execution.stopping = true
|
||||
execution.pendingWake = false
|
||||
if (!options?.preserveWake) execution.pendingWake = false
|
||||
execution.interruptionReason = reason
|
||||
return Fiber.interrupt(execution.owner)
|
||||
})
|
||||
|
||||
@@ -301,6 +301,37 @@ describe("SessionRunCoordinator", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("continues pending work after interruption when preserving the wake", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++runs).pipe(
|
||||
Effect.flatMap((run) =>
|
||||
run === 1
|
||||
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never))
|
||||
: Deferred.succeed(secondStarted, undefined),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* coordinator.wake("session")
|
||||
yield* coordinator.interrupt("session", "user", { preserveWake: true })
|
||||
yield* Deferred.await(secondStarted)
|
||||
|
||||
const exit = yield* Fiber.await(resumed)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||
yield* coordinator.awaitIdle("session")
|
||||
expect(runs).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("runs a wake registered during interruption cleanup", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -132,7 +132,8 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
interrupt: coordinator.interrupt,
|
||||
interrupt: (sessionID, options) =>
|
||||
coordinator.interrupt(sessionID, undefined, { preserveWake: options?.continue }),
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -391,7 +391,8 @@ const execution = Layer.effect(
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
interrupt: coordinator.interrupt,
|
||||
interrupt: (sessionID, options) =>
|
||||
coordinator.interrupt(sessionID, undefined, { preserveWake: options?.continue }),
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -647,6 +647,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.interrupt", "/api/session/:sessionID/interrupt", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: { continue: BooleanFromString.pipe(Schema.optional) },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
@@ -655,7 +656,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.interrupt",
|
||||
summary: "Interrupt session execution",
|
||||
description: "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op.",
|
||||
description:
|
||||
"Interrupt active execution owned by this OpenCode process. When continue=true, pending work starts after interruption. Idle interruption is a no-op.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -772,7 +772,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.interrupt",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.interrupt(ctx.params.sessionID)
|
||||
yield* session.interrupt(ctx.params.sessionID, { continue: ctx.query.continue })
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -432,6 +432,7 @@ export function Prompt(props: PromptProps) {
|
||||
if (store.interrupt >= 2) {
|
||||
void client.api.session.interrupt({
|
||||
sessionID: props.sessionID,
|
||||
continue: true,
|
||||
})
|
||||
setStore("interrupt", 0)
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
void (
|
||||
state.stream
|
||||
? state.stream.then((item) => item.handle.interruptActiveTurn())
|
||||
: state.sdk.session.interrupt({ sessionID: state.sessionID })
|
||||
: state.sdk.session.interrupt({ sessionID: state.sessionID, continue: true })
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
@@ -401,7 +401,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
},
|
||||
onSubagentInterrupt: (sessionID) => {
|
||||
log?.write("send.subagent.interrupt", { sessionID })
|
||||
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
|
||||
void state.sdk.session.interrupt({ sessionID, continue: true }).catch(() => {})
|
||||
},
|
||||
onSubagentSelect: (sessionID) => {
|
||||
state.selectSubagent?.(sessionID)
|
||||
|
||||
@@ -1525,7 +1525,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
state.wait = active
|
||||
const interrupt = () => {
|
||||
active.interrupted = true
|
||||
void sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
void sdk.session.interrupt({ sessionID: input.sessionID, continue: true }).catch(() => {})
|
||||
}
|
||||
next.signal?.addEventListener("abort", interrupt, { once: true })
|
||||
try {
|
||||
@@ -1783,7 +1783,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (state.wait) state.wait.interrupted = true
|
||||
await sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
await sdk.session.interrupt({ sessionID: input.sessionID, continue: true }).catch(() => {})
|
||||
},
|
||||
selectSubagent(sessionID) {
|
||||
subagents.select(sdk, sessionID)
|
||||
|
||||
@@ -217,7 +217,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
run() {
|
||||
const entry = selectedEntry()
|
||||
if (!entry || entry.status !== "running") return
|
||||
void client.api.session.interrupt({ sessionID: entry.sessionID })
|
||||
void client.api.session.interrupt({ sessionID: entry.sessionID, continue: true })
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -89,7 +89,6 @@ import {
|
||||
createSessionRows,
|
||||
messageBoundaryIDs,
|
||||
resolvePart,
|
||||
turnDuration,
|
||||
type CacheUsage,
|
||||
type PartRef,
|
||||
type SessionRow,
|
||||
@@ -1598,7 +1597,6 @@ function SessionGroupView(props: {
|
||||
|
||||
function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
@@ -1609,7 +1607,9 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
.find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
|
||||
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
|
||||
)
|
||||
const duration = createMemo(() => turnDuration(props.message, data.session.message.list(ctx.sessionID)))
|
||||
const duration = createMemo(() =>
|
||||
props.message.time.completed ? props.message.time.completed - props.message.time.created : 0,
|
||||
)
|
||||
const interrupted = createMemo(() => props.message.error?.message === "Step interrupted")
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -348,15 +348,6 @@ export function cacheReuseDrop(previous: CacheUsage | undefined, current: CacheU
|
||||
return drop > 0 ? drop : undefined
|
||||
}
|
||||
|
||||
export function turnDuration(message: SessionMessageAssistant, messages: SessionMessageInfo[]) {
|
||||
if (message.time.completed === undefined) return 0
|
||||
const index = messages.findIndex((item) => item.id === message.id)
|
||||
const input = messages
|
||||
.slice(0, index === -1 ? messages.length : index)
|
||||
.findLast((item) => item.type === "user" || item.type === "synthetic")
|
||||
return Math.max(0, message.time.completed - (input?.time.created ?? message.time.created))
|
||||
}
|
||||
|
||||
function hasTokenUsage(
|
||||
message: SessionMessageAssistant,
|
||||
): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } {
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows, turnDuration } from "../../../src/routes/session/rows"
|
||||
|
||||
test("measures turn duration from the user prompt across assistant steps", () => {
|
||||
const first = assistant("assistant-1", [])
|
||||
first.time = { created: 8_000, completed: 11_000 }
|
||||
const final = assistant("assistant-2", [])
|
||||
final.time = { created: 27_000, completed: 30_000 }
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Question", time: { created: 1_000 } },
|
||||
first,
|
||||
final,
|
||||
]
|
||||
|
||||
expect(turnDuration(final, messages)).toBe(29_000)
|
||||
})
|
||||
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
|
||||
|
||||
test("filters OpenAI cache quantization from cache reuse drops", () => {
|
||||
const openai = { id: "gpt", providerID: "openai" }
|
||||
|
||||
@@ -1496,7 +1496,7 @@ describe("V2 mini transport", () => {
|
||||
await transport.interruptActiveTurn()
|
||||
|
||||
expect(prompt).toHaveBeenCalled()
|
||||
expect(interrupt).toHaveBeenCalledWith({ sessionID: "ses_1" })
|
||||
expect(interrupt).toHaveBeenCalledWith({ sessionID: "ses_1", continue: true })
|
||||
expect(firstPrompt).not.toHaveBeenCalled()
|
||||
expect(firstInterrupt).not.toHaveBeenCalled()
|
||||
await transport.close()
|
||||
@@ -2315,7 +2315,7 @@ describe("V2 mini transport", () => {
|
||||
idle.resolve()
|
||||
await turn
|
||||
|
||||
expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" })
|
||||
expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1", continue: true })
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user