Compare commits

...

7 Commits

Author SHA1 Message Date
Brendan Allan ba47ec90df refactor(app): use native v2 types 2026-08-05 19:35:50 +08:00
Brendan Allan f69ff61600 fix(app): sync session selection before prompts 2026-08-05 19:32:42 +08:00
Brendan Allan a81628ebfe test(app): stabilize reconnect offset timing 2026-08-05 18:53:30 +08:00
Brendan Allan 232ee3b10d test(app): fix v2 timeline fixture ids 2026-08-05 16:15:56 +08:00
Brendan Allan dcd1d457b0 chore(app): merge v2 into v1 removal 2026-08-05 16:02:18 +08:00
Brendan Allan 76b318e990 refactor(app): gate unsupported v2 capabilities (#40381) 2026-08-05 15:15:26 +08:00
opencode-agent[bot] c0ab35c3c2 fix(app): show active search details (#40597)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-05 17:09:42 +10:00
46 changed files with 174 additions and 215 deletions
@@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
),
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
{
id: "prt_0104_text",
id: followingTextID,
sessionID,
messageID: assistantID,
type: "text",
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { ReferenceInfo } from "@/types"
import type { ReferenceInfo } from "@opencode-ai/client/promise"
import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
+1 -1
View File
@@ -81,7 +81,7 @@ import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/place
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import type { ReferenceInfo } from "@/types"
import type { ReferenceInfo } from "@opencode-ai/client/promise"
export { createPromptInputHistory }
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
@@ -1,12 +1,15 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { AgentPartInput, FilePartInput, Part, TextPartInput } from "@/types"
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
type PromptRequestPart =
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
type ContextFile = {
key: string
@@ -31,6 +31,12 @@ const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
const sentCommands: unknown[] = []
const switchedAgents: Array<{ sessionID: string; agent: string }> = []
const switchedModels: Array<{
sessionID: string
model: { id: string; providerID: string; variant?: string }
}> = []
const sessionRequestOrder: string[] = []
const commands: Array<{ name: string }> = []
let serverSessionSyncs = 0
@@ -93,10 +99,22 @@ const clientFor = (directory: string) => {
}
},
prompt: async (input: unknown) => {
sessionRequestOrder.push("prompt")
sentPrompts.push(directory)
promptInputs.push(input)
return { data: undefined }
},
switchAgent: async (input: { sessionID: string; agent: string }) => {
sessionRequestOrder.push("agent")
switchedAgents.push(input)
},
switchModel: async (input: {
sessionID: string
model: { id: string; providerID: string; variant?: string }
}) => {
sessionRequestOrder.push("model")
switchedModels.push(input)
},
command: async (input: unknown) => {
sentCommands.push(input)
},
@@ -279,6 +297,9 @@ beforeEach(() => {
sentPrompts.length = 0
promptInputs.length = 0
sentCommands.length = 0
switchedAgents.length = 0
switchedModels.length = 0
sessionRequestOrder.length = 0
commands.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {}
@@ -436,13 +457,17 @@ describe("prompt submit worktree selection", () => {
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
})
test("includes the selected variant on optimistic prompts", async () => {
test("switches the selected agent and model before prompting", async () => {
params = { id: "session-1" }
variant = "high"
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
info: () => ({
id: "session-1",
agent: "old-agent",
model: { id: "old-model", providerID: "old-provider" },
}),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
@@ -471,6 +496,14 @@ describe("prompt submit worktree selection", () => {
},
})
expect(sentPrompts).toEqual(["/repo/main"])
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
expect(switchedModels).toEqual([
{
sessionID: "session-1",
model: { id: "model", providerID: "provider", variant: "high" },
},
])
expect(sessionRequestOrder).toEqual(["agent", "model", "prompt"])
expect(promptInputs[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
@@ -45,6 +45,7 @@ type FollowupSendInput = {
api: DirectorySDK["api"]["session"]
serverSync: ServerSync
sync: DirectorySync
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean
@@ -157,6 +158,25 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false
}
const session = input.session()
if (session?.agent !== input.draft.agent) {
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
}
if (
session?.model?.providerID !== input.draft.model.providerID ||
session.model.id !== input.draft.model.modelID ||
(session.model.variant ?? "default") !== (input.draft.variant ?? "default")
) {
await input.api.switchModel({
sessionID: input.draft.sessionID,
model: {
id: input.draft.model.modelID,
providerID: input.draft.model.providerID,
variant: input.draft.variant,
},
})
}
await input.api.prompt({
sessionID: input.draft.sessionID,
id: messageID,
@@ -197,7 +217,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
type PromptSubmitInput = {
prompt: ReturnType<typeof usePrompt>
info: Accessor<{ id: string } | undefined>
info: Accessor<
{ id: string; agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined
>
imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number>
autoAccept: Accessor<boolean>
@@ -595,6 +617,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
api: sdk().api.session,
sync: sync(),
serverSync: serverSync(),
session: () => input.info() ?? session,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
@@ -1,11 +1,8 @@
import type {
Config,
Path,
PermissionRequest,
Project,
ProviderAuthResponse,
QuestionRequest,
ReferenceInfo,
Session,
} from "@/types"
import type {
@@ -17,11 +14,14 @@ import type {
CommandListOutput,
LocationGetInput,
LocationGetOutput,
PermissionRequest,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectListOutput,
ReferenceListInput,
ReferenceListOutput,
ReferenceInfo,
QuestionRequest,
SessionApi,
} from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast"
@@ -34,7 +34,6 @@ import type { ServerSession } from "../server-session"
import {
cmp,
normalizeAgentList,
normalizePermissionRequest,
normalizeProjectInfo,
normalizeProviderList,
} from "./utils"
@@ -368,7 +367,7 @@ export async function bootstrapDirectory(input: {
retry(() =>
input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data.map(normalizePermissionRequest))
.then((result) => result.data)
.then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@/types"
import type { Message, Part, Project, Session } from "@/types"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@@ -38,10 +39,10 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
({
id,
sessionID,
permission: title,
patterns: ["*"],
action: title,
resources: ["*"],
metadata: {},
always: [],
save: [],
}) as PermissionRequest
const questionRequest = (id: string, sessionID: string, title = id) =>
@@ -512,7 +513,7 @@ describe("applyDirectoryEvent", () => {
directory: "/tmp",
loadLsp() {},
})
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.permission).toBe("updated")
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.action).toBe("updated")
applyDirectoryEvent({
event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } },
@@ -3,14 +3,11 @@ import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/
import type {
Message,
Part,
PermissionRequest,
Project,
QuestionRequest,
Session,
SessionStatus,
Todo,
} from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SessionV2Info } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import {
applyHomeSessionEvent,
appendHomeSessionEvent,
@@ -48,9 +48,7 @@ describe("Home V2 session index", () => {
calls.push({ input, signal: options.signal })
if (!("cursor" in input)) {
return {
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
session({ id: `page-1-${index}` }),
),
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session({ id: `page-1-${index}` })),
cursor: { next: "next-page" },
}
}
@@ -74,7 +72,7 @@ describe("Home V2 session index", () => {
const activeNull = {
...session({ id: "active-null", updated: 20 }),
time: { created: 1, updated: 20, archived: null },
} as unknown as SessionV2Info
} as unknown as SessionInfo
const result = parseHomeSessionIndex([
session({ id: "root", updated: 30 }),
activeNull,
@@ -1,4 +1,5 @@
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@/types"
import type { Event, Session } from "@/types"
import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import type { QueryClient } from "@tanstack/solid-query"
import { trimSessions } from "./session-trim"
@@ -26,11 +27,11 @@ export async function loadHomeSessionIndex(
list: (
input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal },
) => Promise<V2SessionListResponse>,
) => Promise<SessionsResponse>,
eventSequence = 0,
signal?: AbortSignal,
) {
const data: SessionV2Info[] = []
const data: SessionInfo[] = []
let cursor: string | undefined
for (;;) {
@@ -126,7 +127,7 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
// multiple directories. A bounded page could omit an old session updated today.
// Once released, use client.v2.project.list() and client.v2.session.list({
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
export function parseHomeSessionIndex(sessions: SessionInfo[]): Session[] {
return sessions.flatMap((item) => {
if (item.parentID || typeof item.time.archived === "number") return []
return [toLegacySummary(item)]
@@ -141,7 +142,7 @@ export function retainHomeSessions(sessions: Session[], limit: number, now: numb
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
const info = event.properties.info
const index = sessions.findIndex((session) => session.id === info.id)
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
if (index === -1) return sessions
return sessions.toSpliced(index, 1)
}
@@ -150,7 +151,7 @@ export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEve
return sessions.with(index, info)
}
function toLegacySummary(session: SessionV2Info): Session {
function toLegacySummary(session: SessionInfo): Session {
return {
id: session.id,
slug: session.id,
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
import type { Message, Part, Todo } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -1,4 +1,5 @@
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
import type { Message, Part, Todo } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@/types"
import type { Session } from "@/types"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import { trimSessions } from "./session-trim"
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
@@ -1,4 +1,5 @@
import type { PermissionRequest, Session } from "@/types"
import type { Session } from "@/types"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import { cmp } from "./utils"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
@@ -5,15 +5,17 @@ import type {
Message,
Part,
Path,
PermissionRequest,
QuestionRequest,
ReferenceInfo,
Session,
SessionStatus,
Todo,
VcsInfo,
} from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type {
FileDiffInfo,
PermissionRequest,
QuestionRequest,
ReferenceInfo,
SessionStatus,
} from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Accessor } from "solid-js"
@@ -5,7 +5,7 @@ import type {
ModelListOutput,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => {
@@ -43,30 +43,6 @@ describe("normalizeAgentList", () => {
})
})
describe("normalizePermissionRequest", () => {
test("adapts the current permission request to app state", () => {
expect(
normalizePermissionRequest({
id: "permission-1",
sessionID: "session-1",
action: "read",
resources: ["README.md"],
save: ["*.md"],
metadata: { path: "README.md" },
source: { type: "tool", messageID: "message-1", id: "call-1" },
}),
).toEqual({
id: "permission-1",
sessionID: "session-1",
permission: "read",
patterns: ["README.md"],
always: ["*.md"],
metadata: { path: "README.md" },
tool: { messageID: "message-1", callID: "call-1" },
})
})
})
describe("normalizeProviderList", () => {
test("groups current models into the app provider catalog", () => {
const result = normalizeProviderList(
+1 -18
View File
@@ -2,10 +2,9 @@ import type {
AgentListOutput,
ModelDefaultOutput,
ModelListOutput,
PermissionRequest,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import type { Agent, Event, Project, Provider, ProviderListResponse } from "@/types"
import type { Agent, Project, Provider, ProviderListResponse } from "@/types"
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
@@ -36,22 +35,6 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
}))
}
type LegacyPermissionRequest = Extract<Event, { type: "permission.asked" }>["properties"]
export function normalizePermissionRequest(input: PermissionRequest | LegacyPermissionRequest): LegacyPermissionRequest {
if ("permission" in input) return input
return {
id: input.id,
sessionID: input.sessionID,
permission: input.action,
patterns: input.resources,
always: input.save ?? [],
metadata: input.metadata ?? {},
tool:
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.id } : undefined,
}
}
export function normalizeProviderList(
providers: ProviderListOutput["data"] | ProviderListResponse,
models?: ModelListOutput["data"],
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@/types"
import type { Session } from "@/types"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
+2 -5
View File
@@ -1,7 +1,7 @@
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@/types"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import { Persist, persisted } from "@/utils/persist"
import type { ServerSDK } from "@/context/server-sdk"
import type { ServerSync } from "./server-sync"
@@ -13,7 +13,6 @@ import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
import { normalizePermissionRequest } from "./global-sync/utils"
import {
acceptKey,
directoryAcceptKey,
@@ -256,9 +255,7 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
}
const list = async (directory: string) => {
return input.sdk.api.permission.request
.list({ location: { directory } })
.then((result) => result.data.map(normalizePermissionRequest))
return input.sdk.api.permission.request.list({ location: { directory } }).then((result) => result.data)
}
function respondOnce(permission: PermissionRequest, directory?: string) {
+5 -9
View File
@@ -15,7 +15,7 @@ describe("resumeStreamAfterPageShow", () => {
})
describe("adaptServerEvent", () => {
test("preserves current events while adapting permission requests for existing consumers", () => {
test("preserves current permission requests", () => {
const current = {
id: "evt_1",
created: 1,
@@ -35,9 +35,9 @@ describe("adaptServerEvent", () => {
properties: {
id: "perm_1",
sessionID: "ses_1",
permission: "read",
patterns: ["src/**"],
tool: { messageID: "msg_1", callID: "call_1" },
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
current,
})
@@ -87,11 +87,7 @@ describe("current event buffering", () => {
test("preserves boundaries between distinct delta streams", () => {
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual([
"evt_1",
"evt_2",
"evt_3",
])
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
})
test("preserves current event order when enqueuing", () => {
+1 -20
View File
@@ -1,5 +1,5 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event, PermissionRequest } from "@/types"
import type { Event } from "@/types"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -24,25 +24,6 @@ type CurrentDelta = Extract<
>
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
if (event.type === "permission.asked") {
return {
id: event.id,
type: "permission.asked",
properties: {
id: event.data.id,
sessionID: event.data.sessionID,
permission: event.data.action,
patterns: event.data.resources,
always: event.data.save ?? [],
metadata: event.data.metadata ?? {},
tool:
event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.id }
: undefined,
} satisfies PermissionRequest,
current: event,
}
}
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
+1 -4
View File
@@ -4,13 +4,10 @@ import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai
import type {
Message,
Part,
PermissionRequest,
QuestionRequest,
Session,
SessionStatus,
Todo,
} from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { rootSession } from "@/utils/session-route"
+1 -1
View File
@@ -3,7 +3,6 @@ import type {
Path,
Project,
ProviderAuthResponse,
SessionStatus,
} from "@/types"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
@@ -55,6 +54,7 @@ import type {
McpResourceCatalogOutput,
McpServer,
SessionActiveOutput,
SessionStatus,
} from "@opencode-ai/client/promise"
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession, type ServerSession } from "./server-session"
+5 -3
View File
@@ -1,4 +1,5 @@
import type { FilePart, Project, UserMessage, VcsFileDiff } from "@/types"
import type { FilePart, Project, UserMessage } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
@@ -718,13 +719,13 @@ export default function Page() {
if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending
return true
}
const loadReviewDiff = async (file: string, version?: number): Promise<VcsFileDiff | undefined> => {
const loadReviewDiff = async (file: string, version?: number): Promise<FileDiffInfo | undefined> => {
const mode = vcsMode()
if (!mode) return
const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory)
const directory = reviewDiffDirectory(root, file)
const source = reviewDiffs().find((diff) => diff.file === file)
const valid = (diff: VcsFileDiff | undefined) => {
const valid = (diff: FileDiffInfo | undefined) => {
if (!diff || !source) return
if (diff.additions !== source.additions || diff.deletions !== source.deletions) return
if (reviewDiffNeedsLoad(diff)) return
@@ -1721,6 +1722,7 @@ export default function Page() {
api: sdk().api.session,
sync: sync(),
serverSync: serverSync(),
session: () => sync().session.get(input.sessionID),
draft: item,
optimisticBusy: item.sessionDirectory === sdk().directory,
}).catch((err) => {
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, Session } from "@/types"
import type { Session } from "@/types"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
import { todoDockAtBoundary, todoState } from "./session-composer-state"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
@@ -1,6 +1,7 @@
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { PermissionRequest, QuestionRequest, Todo } from "@/types"
import type { Todo } from "@/types"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
import { useParams } from "@solidjs/router"
import { showToast } from "@/utils/toast"
import { useServerSync } from "@/context/server-sync"
@@ -1,5 +1,5 @@
import { For, Show } from "solid-js"
import type { PermissionRequest } from "@/types"
import type { PermissionRequest } from "@opencode-ai/client/promise"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
@@ -13,7 +13,7 @@ export function SessionPermissionDock(props: {
const language = useLanguage()
const toolDescription = () => {
const key = `settings.permissions.tool.${props.request.permission}.description`
const key = `settings.permissions.tool.${props.request.action}.description`
const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return ""
return value
@@ -59,11 +59,11 @@ export function SessionPermissionDock(props: {
</div>
</Show>
<Show when={props.request.patterns.length > 0}>
<Show when={props.request.resources.length > 0}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-patterns">
<For each={props.request.patterns}>
<For each={props.request.resources}>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
</For>
</div>
@@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { showToast } from "@/utils/toast"
import type { QuestionAnswer, QuestionRequest } from "@/types"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/client/promise"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -1,4 +1,5 @@
import type { PermissionRequest, QuestionRequest, Session } from "@/types"
import type { Session } from "@/types"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
function sessionTreeRequest<T>(
session: Session[],
@@ -1,7 +1,6 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { FileDiffInfo, FileDiffLegacyInfo } from "@opencode-ai/client/promise"
import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type {
SessionReviewCommentActions,
@@ -15,7 +14,7 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split"
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
type ReviewDiff = FileDiffInfo | FileDiffLegacyInfo
export interface SessionReviewTabProps {
title?: JSX.Element
@@ -23,8 +23,7 @@ import { Mark } from "@opencode-ai/ui/logo"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { FileDiffInfo, FileDiffLegacyInfo } from "@opencode-ai/client/promise"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -57,8 +56,8 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
type ReviewDiff = FileDiffInfo | FileDiffLegacyInfo
type RenderDiff = FileDiffInfo | (FileDiffLegacyInfo & { file: string })
function renderDiff(value: ReviewDiff): value is RenderDiff {
return typeof value.file === "string"
@@ -45,8 +45,7 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
await waitFor(() => calls.length === 1)
expect(calls).toEqual([[0, false]])
route.remove()
@@ -197,3 +196,8 @@ async function frames(count: number) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
}
async function waitFor(condition: () => boolean) {
const deadline = performance.now() + 1_000
while (!condition() && performance.now() < deadline) await frames(1)
}
@@ -1,5 +1,5 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@/types"
import type { SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, UserMessage } from "@/types"
import { createMemo, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"
@@ -1,6 +1,6 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { AssistantMessage, Part, SessionStatus, UserMessage } from "@/types"
import type { SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { AssistantMessage, Part, UserMessage } from "@/types"
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
import { uniqueSummaryDiffs } from "./summary-diffs"
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SnapshotFileDiff } from "@/types"
import type { FileDiffLegacyInfo } from "@opencode-ai/client/promise"
import { uniqueSummaryDiffs } from "./summary-diffs"
const diff = (file: string, additions: number) =>
@@ -7,13 +7,13 @@ const diff = (file: string, additions: number) =>
file,
additions,
deletions: 0,
}) satisfies SnapshotFileDiff
}) satisfies FileDiffLegacyInfo
describe("uniqueSummaryDiffs", () => {
test("drops entries without files and preserves unique input", () => {
const alpha = diff("alpha.ts", 1)
const beta = diff("beta.ts", 1)
const invalid = { additions: 1, deletions: 0 } satisfies SnapshotFileDiff
const invalid = { additions: 1, deletions: 0 } satisfies FileDiffLegacyInfo
expect(uniqueSummaryDiffs(undefined)).toEqual([])
expect(uniqueSummaryDiffs([])).toEqual([])
@@ -1,7 +1,7 @@
import type { SnapshotFileDiff } from "@/types"
import type { FileDiffLegacyInfo } from "@opencode-ai/client/promise"
import type { SummaryDiff } from "./timeline-row"
export function uniqueSummaryDiffs(diffs: SnapshotFileDiff[] | undefined) {
export function uniqueSummaryDiffs(diffs: FileDiffLegacyInfo[] | undefined) {
const files = new Set<string>()
return (diffs ?? [])
.reduceRight<SummaryDiff[]>((result, diff) => {
@@ -15,6 +15,6 @@ export function uniqueSummaryDiffs(diffs: SnapshotFileDiff[] | undefined) {
.reverse()
}
function isSummaryDiff(diff: SnapshotFileDiff): diff is SummaryDiff {
function isSummaryDiff(diff: FileDiffLegacyInfo): diff is SummaryDiff {
return typeof diff.file === "string"
}
@@ -1,8 +1,8 @@
import type { SnapshotFileDiff } from "@/types"
import type { FileDiffLegacyInfo } from "@opencode-ai/client/promise"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect"
export type SummaryDiff = SnapshotFileDiff & { file: string }
export type SummaryDiff = FileDiffLegacyInfo & { file: string }
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
@@ -1,6 +1,6 @@
import { useSDK } from "@/context/sdk"
import { Persist, persisted } from "@/utils/persist"
import type { SessionStatus } from "@/types"
import type { SessionStatus } from "@opencode-ai/client/promise"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useSessionLayout } from "./session-layout"
@@ -1,15 +1,14 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { FileDiffInfo, FileDiffLegacyInfo } from "@opencode-ai/client/promise"
import type { Kind } from "@/components/file-tree-v2"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
export type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
export type RenderDiff = FileDiffInfo | (FileDiffLegacyInfo & { file: string })
export function normalizePath(p: string) {
return normalizeFileTreeV2Path(p)
}
export function filterRenderableDiff(value: FileDiffInfo | SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
export function filterRenderableDiff(value: FileDiffInfo | FileDiffLegacyInfo): value is RenderDiff {
return typeof value.file === "string"
}
@@ -1,6 +1,5 @@
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { FileDiffInfo, FileDiffLegacyInfo } from "@opencode-ai/client/promise"
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
@@ -31,7 +30,7 @@ import {
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
type ReviewDiff = FileDiffInfo | FileDiffLegacyInfo
export type ReviewPanelV2Props = {
title?: JSX.Element
+2 -41
View File
@@ -1,34 +1,13 @@
import type {
EventSubscribeOutput,
FileDiffInfo,
FileDiffLegacyInfo,
ProjectListOutput,
QuestionAnswer,
QuestionInfo,
QuestionRequest,
ReferenceInfo,
SessionInfo,
SessionNotFoundError,
SessionStatus,
SessionV1Info,
SessionsResponse,
} from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export type {
QuestionAnswer,
QuestionRequest,
ReferenceInfo,
SessionNotFoundError,
SessionStatus,
}
export type Project = Omit<ProjectListOutput[number], "canonical"> & { worktree: string }
export type Session = Omit<SessionV1Info, "title"> & { title: string }
export type SessionV2Info = SessionInfo
export type V2SessionListResponse = SessionsResponse
export type SnapshotFileDiff = FileDiffLegacyInfo
export type VcsFileDiff = FileDiffInfo
type CurrentEvent = EventSubscribeOutput extends infer Item
? Item extends { type: infer Type extends string; data: infer Data }
@@ -36,22 +15,10 @@ type CurrentEvent = EventSubscribeOutput extends infer Item
: never
: never
export type Event =
| Exclude<CurrentEvent, { type: "permission.asked" }>
| { type: "permission.asked"; properties: PermissionRequest }
export type Event = CurrentEvent
export type EventSessionError = Extract<Event, { type: "session.error" }>
export type PermissionRequest = {
id: string
sessionID: string
permission: string
patterns: string[]
metadata: Record<string, unknown>
always: string[]
tool?: { messageID: string; callID: string }
}
type MessageError =
| { name: "ProviderAuthError"; data: { providerID: string; message: string } }
| { name: "UnknownError"; data: { message: string; ref?: string } }
@@ -78,7 +45,7 @@ export type UserMessage = {
role: "user"
time: { created: number }
format?: { type: "text" } | { type: "json_schema"; schema: Record<string, unknown>; retryCount?: number }
summary?: { title?: string; body?: string; diffs: SnapshotFileDiff[] }
summary?: { title?: string; body?: string; diffs: FileDiffLegacyInfo[] }
agent: string
model: { providerID: string; modelID: string; variant?: string }
system?: string
@@ -330,9 +297,3 @@ export type Config = {
experimental?: Record<string, unknown>
[key: string]: unknown
}
export type TextPartInput = Omit<TextPart, "id" | "sessionID" | "messageID"> & { id?: string }
export type FilePartInput = Omit<FilePart, "id" | "sessionID" | "messageID"> & { id?: string }
export type AgentPartInput = Omit<AgentPart, "id" | "sessionID" | "messageID"> & { id?: string }
export type Question = QuestionInfo
+2 -3
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SnapshotFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { FileDiffInfo, FileDiffLegacyInfo } from "@opencode-ai/client/promise"
import type { Message } from "@/types"
import { diffs, message } from "./diffs"
@@ -10,7 +9,7 @@ const item = {
additions: 1,
deletions: 1,
status: "modified",
} satisfies FileDiffInfo & SnapshotFileDiff
} satisfies FileDiffInfo & FileDiffLegacyInfo
describe("diffs", () => {
test("keeps valid arrays", () => {
+2 -3
View File
@@ -1,8 +1,7 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { FileDiffInfo, FileDiffLegacyInfo } from "@opencode-ai/client/promise"
import type { Message } from "@/types"
type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
type Diff = FileDiffInfo | FileDiffLegacyInfo
function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotFoundError } from "@/types"
import type { SessionNotFoundError } from "@opencode-ai/client/promise"
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors"
import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./server-errors"
@@ -1142,6 +1142,9 @@ export function ContextToolGroup(props: {
const running = createMemo(
() => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
)
const showDetails = createMemo(
() => !running() || partAccessor().tool === "glob" || partAccessor().tool === "grep",
)
return (
<div data-slot="context-tool-group-item">
<div data-component="tool-trigger">
@@ -1152,10 +1155,10 @@ export function ContextToolGroup(props: {
<span data-slot="basic-tool-tool-title">
<TextShimmer text={trigger().title} active={running()} />
</span>
<Show when={!running() && trigger().subtitle}>
<Show when={showDetails() && trigger().subtitle}>
<span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span>
</Show>
<Show when={!running() && trigger().args?.length}>
<Show when={showDetails() && trigger().args?.length}>
<For each={trigger().args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
</For>