refactor(app): establish v2 session controller
This commit is contained in:
parent
99cae9766b
commit
3aaff6ffe2
8 changed files with 285 additions and 154 deletions
|
|
@ -57,7 +57,7 @@ import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { TerminalProvider, useTerminal } from "@/context/terminal"
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
|
|
@ -70,11 +70,11 @@ import {
|
||||||
createSessionComposerRegionController,
|
createSessionComposerRegionController,
|
||||||
SessionComposerRegion,
|
SessionComposerRegion,
|
||||||
} from "@/pages/session/composer"
|
} 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 { MessageTimeline } from "@/pages/session/timeline/message-timeline"
|
||||||
import { createTimelineModel } from "@/pages/session/timeline/model"
|
import { createTimelineModel } from "@/pages/session/timeline/model"
|
||||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
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 { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||||
import {
|
import {
|
||||||
clampSessionPanelWidth,
|
clampSessionPanelWidth,
|
||||||
|
|
@ -101,7 +101,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||||
import { createSessionOwnership } from "./session/session-ownership"
|
|
||||||
import { createSessionLineage } from "./session/session-lineage"
|
import { createSessionLineage } from "./session/session-lineage"
|
||||||
|
|
||||||
type FollowupItem = FollowupDraft & { id: string }
|
type FollowupItem = FollowupDraft & { id: string }
|
||||||
|
|
@ -366,15 +365,28 @@ export default function Page() {
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const terminal = useTerminal()
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||||
|
const canReview = createMemo(() => !!sync().project)
|
||||||
|
const session = createSessionController({
|
||||||
|
review: isDesktop,
|
||||||
|
hasReview: canReview,
|
||||||
|
fileBrowser: (sessionID) => newSessionDesign() && isDesktop() && !!sessionID,
|
||||||
|
})
|
||||||
|
const params = session.identity.params
|
||||||
|
const sessionKey = session.identity.sessionKey
|
||||||
|
const workspaceKey = session.identity.workspaceKey
|
||||||
|
const tabs = session.layout.tabs
|
||||||
|
const view = session.layout.view
|
||||||
const reviewMode = () => view().review.mode() ?? "git"
|
const reviewMode = () => view().review.mode() ?? "git"
|
||||||
const reviewFile = () => view().review.file()
|
const reviewFile = () => view().review.file()
|
||||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
const sessionOwnership = session.ownership
|
||||||
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
|
const info = session.data.info
|
||||||
|
const isChildSession = session.data.isChild
|
||||||
|
const revertMessageID = session.data.revertMessageID
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!prompt.ready()) return
|
if (!prompt.ready()) return
|
||||||
|
|
@ -433,8 +445,8 @@ export default function Page() {
|
||||||
const current = tabs().tabs()
|
const current = tabs().tabs()
|
||||||
if (current.all.length > 0 || current.active) return
|
if (current.all.length > 0 || current.active) return
|
||||||
|
|
||||||
const all = normalizeTabs(from.all)
|
const all = session.tabs.normalizeAll(from.all)
|
||||||
const active = from.active ? normalizeTab(from.active) : undefined
|
const active = from.active ? session.tabs.normalize(from.active) : undefined
|
||||||
tabs().setAll(all)
|
tabs().setAll(all)
|
||||||
tabs().setActive(active && all.includes(active) ? active : all[0])
|
tabs().setActive(active && all.includes(active) ? active : all[0])
|
||||||
|
|
||||||
|
|
@ -445,7 +457,6 @@ export default function Page() {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
|
||||||
const size = createSizing()
|
const size = createSizing()
|
||||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||||
const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id)
|
const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id)
|
||||||
|
|
@ -510,46 +521,16 @@ 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 = () => {
|
const openReviewPanel = () => {
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
const activeTab = session.tabs.activeTab
|
||||||
const isChildSession = createMemo(() => !!info()?.parentID)
|
const activeFileTab = session.tabs.activeFileTab
|
||||||
const canReview = createMemo(() => !!sync().project)
|
const timeline = createTimelineModel({ session })
|
||||||
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 historyLoading = timeline.history.loading
|
const historyLoading = timeline.history.loading
|
||||||
const historyMore = timeline.history.more
|
const historyMore = timeline.history.more
|
||||||
const lastUserMessage = timeline.lastUserMessage
|
const lastUserMessage = timeline.lastUserMessage
|
||||||
const messages = timeline.messages
|
|
||||||
const messagesReady = timeline.ready
|
const messagesReady = timeline.ready
|
||||||
const sessionSync = timeline.resource
|
const sessionSync = timeline.resource
|
||||||
const userMessages = timeline.userMessages
|
const userMessages = timeline.userMessages
|
||||||
|
|
@ -1138,11 +1119,10 @@ export default function Page() {
|
||||||
|
|
||||||
useComposerCommands()
|
useComposerCommands()
|
||||||
useSessionCommands({
|
useSessionCommands({
|
||||||
|
session,
|
||||||
navigateMessageByOffset,
|
navigateMessageByOffset,
|
||||||
setActiveMessage,
|
setActiveMessage,
|
||||||
focusInput,
|
focusInput,
|
||||||
review: reviewTab,
|
|
||||||
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
|
|
||||||
})
|
})
|
||||||
command.register("session-palette", () => [
|
command.register("session-palette", () => [
|
||||||
{
|
{
|
||||||
|
|
@ -1692,8 +1672,6 @@ 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 info>>["revert"], target = sync()) => {
|
||||||
const session = target.session.get(sessionID)
|
const session = target.session.get(sessionID)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
@ -1754,7 +1732,7 @@ export default function Page() {
|
||||||
const queueEnabled = createMemo(() => {
|
const queueEnabled = createMemo(() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return false
|
if (!id) return false
|
||||||
return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession()
|
return settings.general.followup() === "queue" && session.data.working() && !composer.blocked() && !isChildSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
const followupText = (item: FollowupDraft) => {
|
const followupText = (item: FollowupDraft) => {
|
||||||
|
|
@ -1932,7 +1910,7 @@ export default function Page() {
|
||||||
if (followup.paused[sessionID]) return
|
if (followup.paused[sessionID]) return
|
||||||
if (isChildSession()) return
|
if (isChildSession()) return
|
||||||
if (composer.blocked()) return
|
if (composer.blocked()) return
|
||||||
if (busy(sessionID)) return
|
if (session.data.working()) return
|
||||||
|
|
||||||
void sendFollowup(sessionID, item.id)
|
void sendFollowup(sessionID, item.id)
|
||||||
})
|
})
|
||||||
|
|
@ -2080,6 +2058,7 @@ export default function Page() {
|
||||||
<Show when={messagesReady() ? params.id : undefined} keyed>
|
<Show when={messagesReady() ? params.id : undefined} keyed>
|
||||||
{(_id) => (
|
{(_id) => (
|
||||||
<MessageTimeline
|
<MessageTimeline
|
||||||
|
session={session}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
scroll={ui.scroll}
|
scroll={ui.scroll}
|
||||||
onResumeScroll={resumeScroll}
|
onResumeScroll={resumeScroll}
|
||||||
|
|
@ -2157,7 +2136,7 @@ export default function Page() {
|
||||||
: undefined,
|
: undefined,
|
||||||
onResponseSubmit: resumeScroll,
|
onResponseSubmit: resumeScroll,
|
||||||
openParent: () => {
|
openParent: () => {
|
||||||
const id = info()?.parentID
|
const id = session.data.parentID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
navigate(
|
navigate(
|
||||||
params.serverKey
|
params.serverKey
|
||||||
|
|
|
||||||
71
packages/app/src/pages/session/session-controller.test.ts
Normal file
71
packages/app/src/pages/session/session-controller.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
|
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_1"), assistant, user("msg_3"), user("msg_5")]
|
||||||
|
const users = selectSessionUserMessages(messages)
|
||||||
|
|
||||||
|
expect(users.map((message) => message.id)).toEqual(["msg_1", "msg_3", "msg_5"])
|
||||||
|
expect(selectVisibleSessionUserMessages(users, "msg_3").map((message) => message.id)).toEqual(["msg_1"])
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
101
packages/app/src/pages/session/session-controller.ts
Normal file
101
packages/app/src/pages/session/session-controller.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
|
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>
|
||||||
19
packages/app/src/pages/session/session-domain.ts
Normal file
19
packages/app/src/pages/session/session-domain.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
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
|
||||||
|
return messages.filter((message) => message.id < revertMessageID)
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ import { useSettings } from "@/context/settings"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTabs } from "@/context/tabs"
|
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 { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
import { sessionTitle } from "@/utils/session-title"
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
|
@ -24,8 +24,6 @@ import { createTimelineProjection } from "./projection"
|
||||||
|
|
||||||
const emptyMessages: Message[] = []
|
const emptyMessages: Message[] = []
|
||||||
const emptyParts: Part[] = []
|
const emptyParts: Part[] = []
|
||||||
const idle = { type: "idle" as const }
|
|
||||||
|
|
||||||
const taskDescription = (part: Part, sessionID: string): string | undefined => {
|
const taskDescription = (part: Part, sessionID: string): string | undefined => {
|
||||||
if (part.type !== "tool" || part.tool !== "task") return undefined
|
if (part.type !== "tool" || part.tool !== "task") return undefined
|
||||||
const metadata = "metadata" in part.state ? part.state.metadata : 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
|
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 navigate = useNavigate()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
@ -45,14 +52,11 @@ export function createTimelineController(input: { userMessages: Accessor<UserMes
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const { params, sessionKey } = useSessionKey()
|
const params = input.session.identity.params
|
||||||
const sessionID = createMemo(() => params.id)
|
const sessionKey = input.session.identity.sessionKey
|
||||||
const status = createMemo(() => {
|
const sessionID = input.session.identity.sessionID
|
||||||
const id = sessionID()
|
const status = input.session.data.status
|
||||||
if (!id) return idle
|
const messages = input.session.history.messages
|
||||||
return sync().data.session_status[id] ?? idle
|
|
||||||
})
|
|
||||||
const messages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
|
|
||||||
const projectedMessages = createMemo(() => {
|
const projectedMessages = createMemo(() => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
if (!id) return []
|
if (!id) return []
|
||||||
|
|
@ -61,19 +65,13 @@ export function createTimelineController(input: { userMessages: Accessor<UserMes
|
||||||
const projected = sync().data.session_message[id] ?? []
|
const projected = sync().data.session_message[id] ?? []
|
||||||
return boundary ? projected.filter((message) => message.id < boundary) : projected
|
return boundary ? projected.filter((message) => message.id < boundary) : projected
|
||||||
})
|
})
|
||||||
const info = createMemo(() => {
|
const info = input.session.data.info
|
||||||
const id = sessionID()
|
|
||||||
return id ? sync().session.get(id) : undefined
|
|
||||||
})
|
|
||||||
const titleValue = createMemo(() => info()?.title)
|
const titleValue = createMemo(() => info()?.title)
|
||||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||||
const shareUrl = createMemo(() => info()?.share?.url)
|
const shareUrl = createMemo(() => info()?.share?.url)
|
||||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
const parentID = input.session.data.parentID
|
||||||
const parent = createMemo(() => {
|
const parent = input.session.data.parent
|
||||||
const id = parentID()
|
|
||||||
return id ? sync().session.get(id) : undefined
|
|
||||||
})
|
|
||||||
const parentMessages = createMemo(() => {
|
const parentMessages = createMemo(() => {
|
||||||
const id = parentID()
|
const id = parentID()
|
||||||
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
|
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ import { scheduleConnectedMeasure } from "./measure"
|
||||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||||
import { filterVirtualIndexes } from "./virtual-items"
|
import { filterVirtualIndexes } from "./virtual-items"
|
||||||
import { createTimelineController, type TimelineController } from "./controller"
|
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||||
|
|
||||||
const emptyTools: ToolPart[] = []
|
const emptyTools: ToolPart[] = []
|
||||||
const emptyAssistantMessages: AssistantMessage[] = []
|
const emptyAssistantMessages: AssistantMessage[] = []
|
||||||
|
|
@ -205,6 +205,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageTimelineProps = {
|
type MessageTimelineProps = {
|
||||||
|
session: TimelineSessionSource
|
||||||
actions?: UserActions
|
actions?: UserActions
|
||||||
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
|
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
|
||||||
onResumeScroll: () => void
|
onResumeScroll: () => void
|
||||||
|
|
@ -227,7 +228,7 @@ type MessageTimelineProps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageTimeline(props: MessageTimelineProps) {
|
export function MessageTimeline(props: MessageTimelineProps) {
|
||||||
const controller = createTimelineController({ userMessages: () => props.userMessages })
|
const controller = createTimelineController({ session: props.session, userMessages: () => props.userMessages })
|
||||||
return (
|
return (
|
||||||
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
|
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,24 @@
|
||||||
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { Message } from "@opencode-ai/sdk/v2"
|
||||||
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
|
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useSync } from "@/context/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
|
const sessionFreshness = 15_000
|
||||||
|
|
||||||
export function createTimelineModel(input: {
|
export function createTimelineModel(input: { session: Pick<SessionController, "identity" | "history"> }) {
|
||||||
sessionID: Accessor<string | undefined>
|
|
||||||
revertMessageID: Accessor<string | undefined>
|
|
||||||
}) {
|
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
let refreshFrame: number | undefined
|
let refreshFrame: number | undefined
|
||||||
let refreshTimer: number | undefined
|
let refreshTimer: number | undefined
|
||||||
|
|
||||||
const [resource] = createResource(
|
const [resource] = createResource(
|
||||||
() => input.sessionID(),
|
() => input.session.identity.sessionID(),
|
||||||
(id) => {
|
(id) => {
|
||||||
clearRefresh()
|
clearRefresh()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
@ -29,7 +30,7 @@ export function createTimelineModel(input: {
|
||||||
refreshFrame = undefined
|
refreshFrame = undefined
|
||||||
refreshTimer = window.setTimeout(() => {
|
refreshTimer = window.setTimeout(() => {
|
||||||
refreshTimer = undefined
|
refreshTimer = undefined
|
||||||
if (input.sessionID() !== id) return
|
if (input.session.identity.sessionID() !== id) return
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
if (stale) void sync().session.sync(id, { force: true })
|
if (stale) void sync().session.sync(id, { force: true })
|
||||||
})
|
})
|
||||||
|
|
@ -39,33 +40,21 @@ export function createTimelineModel(input: {
|
||||||
return sync().session.sync(id)
|
return sync().session.sync(id)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const messages = createMemo(() => {
|
|
||||||
const id = input.sessionID()
|
|
||||||
return id ? (sync().data.message[id] ?? []) : []
|
|
||||||
})
|
|
||||||
const ready = createMemo(() => {
|
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))
|
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 more = createMemo(() => {
|
||||||
const id = input.sessionID()
|
const id = input.session.identity.sessionID()
|
||||||
return id ? sync().session.history.more(id) : false
|
return id ? sync().session.history.more(id) : false
|
||||||
})
|
})
|
||||||
const loading = createMemo(() => {
|
const loading = createMemo(() => {
|
||||||
const id = input.sessionID()
|
const id = input.session.identity.sessionID()
|
||||||
return id ? sync().session.history.loading(id) : false
|
return id ? sync().session.history.loading(id) : false
|
||||||
})
|
})
|
||||||
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
|
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
|
||||||
return loadOlderTimeline({
|
return loadOlderTimeline({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.session.identity.sessionID,
|
||||||
more,
|
more,
|
||||||
loading,
|
loading,
|
||||||
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
||||||
|
|
@ -78,12 +67,12 @@ export function createTimelineModel(input: {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
history: { loadOlder, loading, more },
|
history: { loadOlder, loading, more },
|
||||||
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
|
lastUserMessage: input.session.history.lastUserMessage,
|
||||||
messages,
|
messages: input.session.history.messages,
|
||||||
ready,
|
ready,
|
||||||
resource,
|
resource,
|
||||||
userMessages,
|
userMessages: input.session.history.userMessages,
|
||||||
visibleUserMessages,
|
visibleUserMessages: input.session.history.visibleUserMessages,
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRefresh() {
|
function clearRefresh() {
|
||||||
|
|
@ -94,19 +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) {
|
export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
|
||||||
return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
|
return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
|
|
||||||
if (!revertMessageID) return messages
|
|
||||||
return messages.filter((message) => message.id < revertMessageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadOlderTimeline(input: {
|
export async function loadOlderTimeline(input: {
|
||||||
sessionID: Accessor<string | undefined>
|
sessionID: Accessor<string | undefined>
|
||||||
more: Accessor<boolean>
|
more: Accessor<boolean>
|
||||||
|
|
|
||||||
|
|
@ -13,19 +13,25 @@ import { useSync } from "@/context/sync"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { findLast } from "@opencode-ai/core/util/array"
|
import { findLast } from "@opencode-ai/core/util/array"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
|
||||||
import { createSessionOwnership } from "./session-ownership"
|
|
||||||
import { useLocal } from "@/context/local"
|
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 = {
|
export type SessionCommandContext = {
|
||||||
|
session: SessionCommandSource
|
||||||
navigateMessageByOffset: (offset: number) => void
|
navigateMessageByOffset: (offset: number) => void
|
||||||
setActiveMessage: (message: UserMessage | undefined) => void
|
setActiveMessage: (message: UserMessage | undefined) => void
|
||||||
focusInput: () => void
|
focusInput: () => void
|
||||||
review?: () => boolean
|
|
||||||
fileBrowser?: () => boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const withCategory = (category: string) => {
|
const withCategory = (category: string) => {
|
||||||
|
|
@ -49,15 +55,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
const params = actions.session.identity.params
|
||||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
const tabs = actions.session.layout.tabs
|
||||||
|
const view = actions.session.layout.view
|
||||||
|
const sessionOwnership = actions.session.ownership
|
||||||
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
||||||
const owner = sessionOwnership.capture()
|
const owner = sessionOwnership.capture()
|
||||||
const value = await load()
|
const value = await load()
|
||||||
owner.run(() => show(value))
|
owner.run(() => show(value))
|
||||||
}
|
}
|
||||||
const runCommand = async <T,>(input: {
|
const runCommand = async <T,>(input: {
|
||||||
owner: ReturnType<ReturnType<typeof createSessionOwnership>["capture"]>
|
owner: ReturnType<SessionController["ownership"]["capture"]>
|
||||||
prompt: T
|
prompt: T
|
||||||
request: () => Promise<unknown>
|
request: () => Promise<unknown>
|
||||||
updatePrompt: (prompt: T) => void
|
updatePrompt: (prompt: T) => void
|
||||||
|
|
@ -68,39 +76,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
input.owner.run(input.updateViewport)
|
input.owner.run(input.updateViewport)
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = () => {
|
const info = actions.session.data.info
|
||||||
const id = params.id
|
const activeFileTab = actions.session.tabs.activeFileTab
|
||||||
if (!id) return
|
const closableTab = actions.session.tabs.closableTab
|
||||||
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 shown = settings.visibility.fileTree
|
||||||
|
|
||||||
const messages = () => {
|
const userMessages = actions.session.history.userMessages
|
||||||
const id = params.id
|
const visibleUserMessages = actions.session.history.visibleUserMessages
|
||||||
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()
|
|
||||||
return userMessages().filter((m) => m.id < revert)
|
|
||||||
}
|
|
||||||
|
|
||||||
const showAllFiles = () => {
|
const showAllFiles = () => {
|
||||||
if (layout.fileTree.tab() !== "changes") return
|
if (layout.fileTree.tab() !== "changes") return
|
||||||
|
|
@ -309,7 +291,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const session = sdk().api.session
|
const session = sdk().api.session
|
||||||
const directory = sdk().directory
|
const directory = sdk().directory
|
||||||
const promptSession = prompt.capture()
|
const promptSession = prompt.capture()
|
||||||
const revert = info()?.revert?.messageID
|
const revert = actions.session.data.revertMessageID()
|
||||||
const messages = userMessages()
|
const messages = userMessages()
|
||||||
const message = findLast(messages, (x) => !revert || x.id < revert)
|
const message = findLast(messages, (x) => !revert || x.id < revert)
|
||||||
if (!message) return
|
if (!message) return
|
||||||
|
|
@ -338,7 +320,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const messages = userMessages()
|
const messages = userMessages()
|
||||||
const promptSession = prompt.capture()
|
const promptSession = prompt.capture()
|
||||||
|
|
||||||
const revertMessageID = info()?.revert?.messageID
|
const revertMessageID = actions.session.data.revertMessageID()
|
||||||
if (!revertMessageID) return
|
if (!revertMessageID) return
|
||||||
|
|
||||||
const next = messages.find((x) => x.id > revertMessageID)
|
const next = messages.find((x) => x.id > revertMessageID)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue