mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 21:56:17 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
910481211b |
@@ -57,7 +57,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"
|
||||
@@ -70,11 +70,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,
|
||||
@@ -101,7 +101,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 }
|
||||
@@ -366,15 +365,28 @@ 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 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 reviewFile = () => view().review.file()
|
||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
const sessionOwnership = session.ownership
|
||||
const info = session.data.info
|
||||
const isChildSession = session.data.isChild
|
||||
const revertMessageID = session.data.revertMessageID
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
@@ -433,8 +445,8 @@ export default function Page() {
|
||||
const current = tabs().tabs()
|
||||
if (current.all.length > 0 || current.active) return
|
||||
|
||||
const all = normalizeTabs(from.all)
|
||||
const active = from.active ? normalizeTab(from.active) : undefined
|
||||
const all = session.tabs.normalizeAll(from.all)
|
||||
const active = from.active ? session.tabs.normalize(from.active) : undefined
|
||||
tabs().setAll(all)
|
||||
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 desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
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 = () => {
|
||||
if (!view().reviewPanel.opened()) 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 activeTab = session.tabs.activeTab
|
||||
const activeFileTab = session.tabs.activeFileTab
|
||||
const timeline = createTimelineModel({ session })
|
||||
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
|
||||
@@ -1138,11 +1119,10 @@ export default function Page() {
|
||||
|
||||
useComposerCommands()
|
||||
useSessionCommands({
|
||||
session,
|
||||
navigateMessageByOffset,
|
||||
setActiveMessage,
|
||||
focusInput,
|
||||
review: reviewTab,
|
||||
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
|
||||
})
|
||||
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 session = target.session.get(sessionID)
|
||||
if (!session) return
|
||||
@@ -1754,7 +1732,7 @@ export default function Page() {
|
||||
const queueEnabled = createMemo(() => {
|
||||
const id = params.id
|
||||
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) => {
|
||||
@@ -1932,7 +1910,7 @@ export default function Page() {
|
||||
if (followup.paused[sessionID]) return
|
||||
if (isChildSession()) return
|
||||
if (composer.blocked()) return
|
||||
if (busy(sessionID)) return
|
||||
if (session.data.working()) return
|
||||
|
||||
void sendFollowup(sessionID, item.id)
|
||||
})
|
||||
@@ -2080,6 +2058,7 @@ export default function Page() {
|
||||
<Show when={messagesReady() ? params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
session={session}
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
@@ -2157,7 +2136,7 @@ export default function Page() {
|
||||
: undefined,
|
||||
onResponseSubmit: resumeScroll,
|
||||
openParent: () => {
|
||||
const id = info()?.parentID
|
||||
const id = session.data.parentID()
|
||||
if (!id) return
|
||||
navigate(
|
||||
params.serverKey
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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 { 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 { showToast } from "@/utils/toast"
|
||||
@@ -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 serverSDK = useServerSDK()
|
||||
const sdk = useSDK()
|
||||
@@ -45,14 +52,11 @@ 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 params = input.session.identity.params
|
||||
const sessionKey = input.session.identity.sessionKey
|
||||
const sessionID = input.session.identity.sessionID
|
||||
const status = input.session.data.status
|
||||
const messages = input.session.history.messages
|
||||
const projectedMessages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return []
|
||||
@@ -61,19 +65,13 @@ export function createTimelineController(input: { userMessages: Accessor<UserMes
|
||||
const projected = sync().data.session_message[id] ?? []
|
||||
return boundary ? projected.filter((message) => message.id < boundary) : projected
|
||||
})
|
||||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
return id ? sync().session.get(id) : undefined
|
||||
})
|
||||
const info = input.session.data.info
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const parent = createMemo(() => {
|
||||
const id = parentID()
|
||||
return id ? sync().session.get(id) : undefined
|
||||
})
|
||||
const parentID = input.session.data.parentID
|
||||
const parent = input.session.data.parent
|
||||
const parentMessages = createMemo(() => {
|
||||
const id = parentID()
|
||||
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
|
||||
|
||||
@@ -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[] = []
|
||||
@@ -205,6 +205,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||
}
|
||||
|
||||
type MessageTimelineProps = {
|
||||
session: TimelineSessionSource
|
||||
actions?: UserActions
|
||||
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
|
||||
onResumeScroll: () => void
|
||||
@@ -227,7 +228,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 "@opencode-ai/sdk/v2"
|
||||
import type { Message } from "@opencode-ai/sdk/v2"
|
||||
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,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) {
|
||||
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: {
|
||||
sessionID: Accessor<string | undefined>
|
||||
more: Accessor<boolean>
|
||||
|
||||
@@ -13,19 +13,25 @@ import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { findLast } from "@opencode-ai/core/util/array"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
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) => {
|
||||
@@ -49,15 +55,17 @@ 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 params = actions.session.identity.params
|
||||
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 owner = sessionOwnership.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
|
||||
@@ -68,39 +76,13 @@ 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 info = actions.session.data.info
|
||||
const activeFileTab = actions.session.tabs.activeFileTab
|
||||
const closableTab = actions.session.tabs.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()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
}
|
||||
const userMessages = actions.session.history.userMessages
|
||||
const visibleUserMessages = actions.session.history.visibleUserMessages
|
||||
|
||||
const showAllFiles = () => {
|
||||
if (layout.fileTree.tab() !== "changes") return
|
||||
@@ -309,7 +291,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const session = sdk().api.session
|
||||
const directory = sdk().directory
|
||||
const promptSession = prompt.capture()
|
||||
const revert = info()?.revert?.messageID
|
||||
const revert = actions.session.data.revertMessageID()
|
||||
const messages = userMessages()
|
||||
const message = findLast(messages, (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
@@ -338,7 +320,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const messages = userMessages()
|
||||
const promptSession = prompt.capture()
|
||||
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
const revertMessageID = actions.session.data.revertMessageID()
|
||||
if (!revertMessageID) return
|
||||
|
||||
const next = messages.find((x) => x.id > revertMessageID)
|
||||
|
||||
Reference in New Issue
Block a user