Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline 717cdeed41 feat(core): expose previous agent on selection events 2026-08-09 21:39:18 +00:00
Kit Langton 84fd347afa fix(codegen): write prettier-stable generated manifests (#41343) 2026-08-08 20:52:04 -04:00
12 changed files with 30 additions and 98 deletions
+5 -1
View File
@@ -339,7 +339,11 @@ export type Endpoint5_31Output =
readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
readonly data: {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly previous?: Agent.ID | undefined
}
}
| {
readonly id: Event.ID
@@ -439,7 +439,7 @@ export type SessionAgentSelected = {
type: "session.agent.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; agent: string }
data: { sessionID: string; agent: string; previous?: string }
}
export type SessionModelSelected = {
+2 -1
View File
@@ -716,10 +716,11 @@ const layer = Layer.effect(
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
const session = yield* result.get(input.sessionID)
yield* bus.publish(SessionEvent.AgentSelected, {
sessionID: input.sessionID,
agent: input.agent,
previous: session.agent,
})
}),
switchModel: Effect.fn("Session.switchModel")(function* (input) {
+2 -2
View File
@@ -648,14 +648,14 @@ describe("Session.create", () => {
it.effect("switches the selected agent through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
const created = yield* session.create({ location, agent: Agent.defaultID })
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan", previous: "build" } }])
}),
)
+9 -1
View File
@@ -1316,7 +1316,15 @@ export function write(
}).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))),
{ concurrency: 8, discard: true },
)
yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n")
// Format the manifest with the same prettier settings as the repo-wide
// format pass, so `check:generated` stays clean after the generate bot
// reformats the tree.
const manifestJson = JSON.stringify(output.files.map((file) => file.path).sort())
const manifestContent = yield* Effect.tryPromise({
try: () => format(manifestJson, { filepath: manifest, parser: "json", printWidth: 120 }),
catch: (error) => new GenerationError({ reason: `Failed to format ${manifest}: ${String(error)}` }),
})
yield* fs.writeFileString(manifest, manifestContent)
})
}
+1 -1
View File
@@ -16,7 +16,7 @@ describe("HttpApiCodegen.write", () => {
expect(writes).toEqual([
{ path: "/generated/session.ts", content: "export const session = {}\n" },
{ path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' },
{ path: "/generated/.httpapi-codegen.json", content: '["session.ts"]\n' },
])
}).pipe(
Effect.provideService(
+1
View File
@@ -69,6 +69,7 @@ export const AgentSelected = Event.durable({
schema: {
...Base,
agent: Agent.ID,
previous: Agent.ID.pipe(optional),
},
})
export type AgentSelected = typeof AgentSelected.Type
+3 -18
View File
@@ -10,7 +10,6 @@ import {
moveSessionTab,
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
sessionTabDetail,
sessionTabShortcutLabel,
seedSessionTabMotion,
sessionTabOverflowWidth,
@@ -149,15 +148,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
if (tab === NEW_SESSION_TAB) return "Start a new session"
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
const value = session()
const projectLabel = projectName(project(), value?.location.directory) ?? ""
const vcs = value ? data.location.vcs.info(value.location) : undefined
return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default)
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
})
const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth()))
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
const detailFades = createMemo(() => stringWidth(detail()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const background = createMemo(() => {
if (selected()) return theme.background.action.primary.selected
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
@@ -190,11 +184,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
const detailTextColor = (index: number) => {
if (!detailFades() || index < visibleDetailParts().length - FADE_WIDTH) return detailColor()
const position = index - (visibleDetailParts().length - FADE_WIDTH)
return tint(detailColor(), pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
}
const glows = () => status().glows
const previous = createMemo(() => items()[index() - 1])
const previousStatus = createMemo(() => {
@@ -371,11 +360,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
<Show when={detailFades()} fallback={visibleDetail()}>
<For each={visibleDetailParts()}>
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
</For>
</Show>
{detail()}
</text>
</box>
</box>
@@ -13,16 +13,6 @@ export function sessionTabShortcutLabel(index: number) {
return "·"
}
export function sessionTabBranch(current: string | undefined, defaultBranch: string | undefined) {
if (!current || current === defaultBranch) return undefined
return current
}
export function sessionTabDetail(project: string, current: string | undefined, defaultBranch: string | undefined) {
const branch = sessionTabBranch(current, defaultBranch)
return branch && project ? `${project}:${branch}` : (branch ?? project)
}
export type SessionTabHistory = {
entries: readonly string[]
index: number
+4 -15
View File
@@ -157,9 +157,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
// Load lightweight session and location metadata concurrently so persisted tabs can resolve
// their project and branch labels. Delay the heavier per-tab data so the visible session keeps
// the first connection slots and switches still render from a warm cache.
// Load lightweight session metadata concurrently so persisted tabs can resolve their project
// labels immediately. Delay the heavier per-tab data so the visible session keeps the first
// connection slots and switches still render from a warm cache.
const openTabSessions = createMemo(() =>
state()
.tabs.map((tab) => tab.sessionID)
@@ -171,19 +171,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (client.connection.status() !== "connected") return
const sessionIDs = openTabSessions()
if (sessionIDs === "") return
void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
let stale = false
void (async () => {
await Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
if (stale) return
const locations = new Map(
sessionIDs
.split("\n")
.map((sessionID) => data.session.get(sessionID)?.location)
.filter((location) => location !== undefined)
.map((location) => [`${location.directory}\n${location.workspaceID ?? ""}`, location]),
)
await Promise.allSettled(Array.from(locations.values(), (location) => data.location.vcs.sync(location)))
})()
const timer = setTimeout(async () => {
const sessions = state()
.tabs.map((tab) => tab.sessionID)
@@ -11,25 +11,11 @@ import {
reopenSessionTab,
seedSessionTabMotion,
sessionTabComplete,
sessionTabBranch,
sessionTabDetail,
sessionTabOverflowWidth,
sessionTabShortcutLabel,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("shows only non-default session branches", () => {
expect(sessionTabBranch("main", "main")).toBeUndefined()
expect(sessionTabBranch("feature/sidebar", "main")).toBe("feature/sidebar")
expect(sessionTabBranch("feature/sidebar", undefined)).toBe("feature/sidebar")
expect(sessionTabBranch(undefined, "main")).toBeUndefined()
})
test("separates the project and branch with a colon", () => {
expect(sessionTabDetail("opencode", "feature/sidebar", "main")).toBe("opencode:feature/sidebar")
expect(sessionTabDetail("opencode", "main", "main")).toBe("opencode")
})
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
"1",
@@ -27,14 +27,7 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
async function renderSessionTabs(
initialSessionID: string,
options?: {
state?: string
title?: string
home?: boolean
persisted?: string[]
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
},
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
) {
const temporary = options?.state ? undefined : await tmpdir()
const state = options?.state ?? temporary!.path
@@ -51,16 +44,7 @@ async function renderSessionTabs(
}
const events = createEventStream()
const sessions: string[] = []
const vcsLocations: string[] = []
const calls = createFetch(async (url) => {
if (url.pathname === "/api/vcs") {
const requested = url.searchParams.get("location[directory]") ?? directory
vcsLocations.push(requested)
return json({
location: { directory: requested },
data: { branch: { current: "main", default: "main" } },
})
}
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (!sessionID) return undefined
sessions.push(sessionID)
@@ -70,7 +54,7 @@ async function renderSessionTabs(
id: sessionID,
title: sessionID === initialSessionID ? options?.title : undefined,
projectID: "project",
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
@@ -120,7 +104,6 @@ async function renderSessionTabs(
route,
data,
sessions,
vcsLocations,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
async destroy() {
@@ -151,21 +134,6 @@ test("loads persisted tab metadata concurrently on connect", async () => {
}
})
test("loads VCS metadata for each persisted tab location", async () => {
const other = `${directory}/other-worktree`
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first", "second"],
sessionDirectories: { second: other },
})
try {
await wait(() => setup.vcsLocations.includes(other))
} finally {
await setup.destroy()
}
})
test("stores session tabs for the current working directory by default", async () => {
const setup = await renderSessionTabs("first")