From 01c5eb679cd0b00cba654b316ef8615d0262c500 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 7 Apr 2026 10:08:57 -0400 Subject: [PATCH 01/23] go: support coupon --- infra/console.ts | 8 +++++ .../console/app/src/routes/stripe/webhook.ts | 33 +++++-------------- packages/console/core/src/billing.ts | 2 +- packages/console/core/src/lite.ts | 7 +++- packages/console/core/sst-env.d.ts | 5 +++ packages/console/function/sst-env.d.ts | 5 +++ packages/console/resource/sst-env.d.ts | 5 +++ packages/enterprise/sst-env.d.ts | 5 +++ packages/function/sst-env.d.ts | 5 +++ sst-env.d.ts | 5 +++ 10 files changed, 53 insertions(+), 27 deletions(-) diff --git a/infra/console.ts b/infra/console.ts index 22652f2da..8925f37d5 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -109,6 +109,12 @@ const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", appliesToProducts: [zenLiteProduct.id], duration: "once", }) +const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100", { + name: "First month 100% off", + percentOff: 100, + appliesToProducts: [zenLiteProduct.id], + duration: "once", +}) const zenLitePrice = new stripe.Price("ZenLitePrice", { product: zenLiteProduct.id, currency: "usd", @@ -124,6 +130,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", { price: zenLitePrice.id, priceInr: 92900, firstMonth50Coupon: zenLiteCouponFirstMonth50.id, + firstMonth100Coupon: zenLiteCouponFirstMonth100.id, }, }) @@ -229,6 +236,7 @@ new sst.cloudflare.x.SolidStart("Console", { SALESFORCE_INSTANCE_URL, ZEN_BLACK_PRICE, ZEN_LITE_PRICE, + new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"), new sst.Secret("ZEN_LIMITS"), new sst.Secret("ZEN_SESSION_SECRET"), ...ZEN_MODELS, diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index 47fee05cf..0d8cf61cf 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -1,3 +1,4 @@ +import type { Stripe } from "stripe" import { Billing } from "@opencode-ai/console-core/billing.js" import type { APIEvent } from "@solidjs/start/server" import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js" @@ -111,27 +112,17 @@ export async function POST(input: APIEvent) { const customerID = body.data.object.customer as string const invoiceID = body.data.object.latest_invoice as string const subscriptionID = body.data.object.id as string + const paymentMethodID = body.data.object.default_payment_method as string if (!workspaceID) throw new Error("Workspace ID not found") if (!userID) throw new Error("User ID not found") if (!customerID) throw new Error("Customer ID not found") if (!invoiceID) throw new Error("Invoice ID not found") if (!subscriptionID) throw new Error("Subscription ID not found") - - // get payment id from invoice - const invoice = await Billing.stripe().invoices.retrieve(invoiceID, { - expand: ["payments"], - }) - const paymentID = invoice.payments?.data[0].payment.payment_intent as string - if (!paymentID) throw new Error("Payment ID not found") + if (!paymentMethodID) throw new Error("Payment method ID not found") // get payment method for the payment intent - const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, { - expand: ["payment_method"], - }) - const paymentMethod = paymentIntent.payment_method - if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded") - + const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID) await Actor.provide("system", { workspaceID }, async () => { // look up current billing const billing = await Billing.get() @@ -200,26 +191,18 @@ export async function POST(input: APIEvent) { const amountInCents = body.data.object.amount_paid const customerID = body.data.object.customer as string const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string + const productID = body.data.object.lines?.data[0].pricing?.price_details?.product as string if (!customerID) throw new Error("Customer ID not found") if (!invoiceID) throw new Error("Invoice ID not found") if (!subscriptionID) throw new Error("Subscription ID not found") // get coupon id from subscription - const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscriptionID, { - expand: ["discounts"], - }) - const couponID = - typeof subscriptionData.discounts[0] === "string" - ? subscriptionData.discounts[0] - : subscriptionData.discounts[0]?.coupon?.id - const productID = subscriptionData.items.data[0].price.product as string - - // get payment id from invoice const invoice = await Billing.stripe().invoices.retrieve(invoiceID, { - expand: ["payments"], + expand: ["discounts", "payments"], }) - const paymentID = invoice.payments?.data[0].payment.payment_intent as string + const paymentID = invoice.payments?.data[0]?.payment.payment_intent as string + const couponID = (invoice.discounts[0] as Stripe.Discount).coupon?.id as string if (!paymentID) { // payment id can be undefined when using coupon if (!couponID) throw new Error("Payment ID not found") diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 66b980698..9de413e60 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -254,7 +254,7 @@ export namespace Billing { const createSession = () => Billing.stripe().checkout.sessions.create({ mode: "subscription", - discounts: [{ coupon: LiteData.firstMonth50Coupon() }], + discounts: [{ coupon: LiteData.firstMonthCoupon(email!) }], ...(billing.customerID ? { customer: billing.customerID, diff --git a/packages/console/core/src/lite.ts b/packages/console/core/src/lite.ts index 2c4a09f71..3343192c1 100644 --- a/packages/console/core/src/lite.ts +++ b/packages/console/core/src/lite.ts @@ -11,6 +11,11 @@ export namespace LiteData { export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product) export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price) export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr) - export const firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon) + export const firstMonthCoupon = fn(z.string(), (email) => { + const invitees = Resource.ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES.value.split(",") + return invitees.includes(email) + ? Resource.ZEN_LITE_PRICE.firstMonth100Coupon + : Resource.ZEN_LITE_PRICE.firstMonth50Coupon + }) export const planName = fn(z.void(), () => "lite") } diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index 6b842639a..b77ee3c5b 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -142,7 +142,12 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_LITE_PRICE": { + "firstMonth100Coupon": string "firstMonth50Coupon": string "price": string "priceInr": number diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index 6b842639a..b77ee3c5b 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -142,7 +142,12 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_LITE_PRICE": { + "firstMonth100Coupon": string "firstMonth50Coupon": string "price": string "priceInr": number diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index 6b842639a..b77ee3c5b 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -142,7 +142,12 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_LITE_PRICE": { + "firstMonth100Coupon": string "firstMonth50Coupon": string "price": string "priceInr": number diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index 6b842639a..b77ee3c5b 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -142,7 +142,12 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_LITE_PRICE": { + "firstMonth100Coupon": string "firstMonth50Coupon": string "price": string "priceInr": number diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index 6b842639a..b77ee3c5b 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -142,7 +142,12 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_LITE_PRICE": { + "firstMonth100Coupon": string "firstMonth50Coupon": string "price": string "priceInr": number diff --git a/sst-env.d.ts b/sst-env.d.ts index c9e567997..2a40a9f3c 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -168,7 +168,12 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_LITE_PRICE": { + "firstMonth100Coupon": string "firstMonth50Coupon": string "price": string "priceInr": number From 1f94c48bdd7ea69f05574ae90f20436a801c9ca2 Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 7 Apr 2026 10:12:53 -0400 Subject: [PATCH 02/23] fix(opencode): keep user message variants scoped to model (#21332) --- .../app/src/components/prompt-input/submit.test.ts | 5 ++--- packages/app/src/components/prompt-input/submit.ts | 3 +-- packages/app/src/context/local.tsx | 6 +++--- packages/app/src/context/sync.tsx | 3 +-- .../src/pages/session/session-model-helpers.test.ts | 9 +++++---- .../src/cli/cmd/tui/component/prompt/index.tsx | 10 ++++++---- packages/opencode/src/session/compaction.ts | 3 +-- packages/opencode/src/session/llm.ts | 4 +++- packages/opencode/src/session/message-v2.ts | 2 +- packages/opencode/src/session/prompt.ts | 13 ++++++++----- packages/opencode/test/session/llm.test.ts | 6 ++---- packages/opencode/test/session/prompt.test.ts | 12 ++++++++---- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- 13 files changed, 42 insertions(+), 36 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index b0166c43a..03bece2e3 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -146,7 +146,7 @@ beforeAll(async () => { add: (value: { directory?: string sessionID?: string - message: { agent: string; model: { providerID: string; modelID: string }; variant?: string } + message: { agent: string; model: { providerID: string; modelID: string; variant?: string } } }) => { optimistic.push(value) optimisticSeeded.push( @@ -310,8 +310,7 @@ describe("prompt submit worktree selection", () => { expect(optimistic[0]).toMatchObject({ message: { agent: "agent", - model: { providerID: "provider", modelID: "model" }, - variant: "high", + model: { providerID: "provider", modelID: "model", variant: "high" }, }, }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 06b6c1e35..2a3a3d0e9 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -121,8 +121,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { role: "user", time: { created: Date.now() }, agent: input.draft.agent, - model: input.draft.model, - variant: input.draft.variant, + model: { ...input.draft.model, variant: input.draft.variant }, } const add = () => diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 84a613c0d..1633607de 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -11,7 +11,7 @@ import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } fro import { useSDK } from "./sdk" import { useSync } from "./sync" -export type ModelKey = { providerID: string; modelID: string } +export type ModelKey = { providerID: string; modelID: string; variant?: string } type State = { agent?: string @@ -373,7 +373,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ handoff.set(handoffKey(dir, session), next) setStore("draft", undefined) }, - restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) { + restore(msg: { sessionID: string; agent: string; model: ModelKey }) { const session = id() if (!session) return if (msg.sessionID !== session) return @@ -383,7 +383,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setSaved("session", session, { agent: msg.agent, model: msg.model, - variant: msg.variant ?? null, + variant: msg.model.variant ?? null, }) }, }, diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index bbf4fc5ec..b023e8ddc 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -416,8 +416,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ role: "user", time: { created: Date.now() }, agent: input.agent, - model: input.model, - variant: input.variant, + model: { ...input.model, variant: input.variant }, } const [, setStore] = target() setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts }) diff --git a/packages/app/src/pages/session/session-model-helpers.test.ts b/packages/app/src/pages/session/session-model-helpers.test.ts index 319db805d..2ab293b8f 100644 --- a/packages/app/src/pages/session/session-model-helpers.test.ts +++ b/packages/app/src/pages/session/session-model-helpers.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import type { UserMessage } from "@opencode-ai/sdk/v2" import { resetSessionModel, syncSessionModel } from "./session-model-helpers" -const message = (input?: Partial>) => +const message = (input?: { agent?: string; model?: UserMessage["model"] }) => ({ id: "msg", sessionID: "session", @@ -10,7 +10,6 @@ const message = (input?: Partial { @@ -26,10 +25,12 @@ describe("syncSessionModel", () => { reset() {}, }, }, - message({ variant: "high" }), + message({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }), ) - expect(calls).toEqual([message({ variant: "high" })]) + expect(calls).toEqual([ + message({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }), + ]) }) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 2fef184f5..045d730c9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -23,7 +23,7 @@ import { useRenderer, type JSX } from "@opentui/solid" import { Editor } from "@tui/util/editor" import { useExit } from "../../context/exit" import { Clipboard } from "../../util/clipboard" -import type { AssistantMessage, FilePart } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" import { Locale } from "@/util/locale" @@ -145,7 +145,7 @@ export function Prompt(props: PromptProps) { if (!props.sessionID) return undefined const messages = sync.data.message[props.sessionID] if (!messages) return undefined - return messages.findLast((m) => m.role === "user") + return messages.findLast((m): m is UserMessage => m.role === "user") }) const usage = createMemo(() => { @@ -209,8 +209,10 @@ export function Prompt(props: PromptProps) { const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent) if (msg.agent && isPrimaryAgent) { local.agent.set(msg.agent) - if (msg.model) local.model.set(msg.model) - if (msg.variant) local.model.variant.set(msg.variant) + if (msg.model) { + local.model.set(msg.model) + local.model.variant.set(msg.model.variant) + } } } }) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 3158393f1..bbdce9fd7 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -228,7 +228,7 @@ When constructing the summary, try to stick to this template: sessionID: input.sessionID, mode: "compaction", agent: "compaction", - variant: userMessage.variant, + variant: userMessage.model.variant, summary: true, path: { cwd: ctx.directory, @@ -295,7 +295,6 @@ When constructing the summary, try to stick to this template: format: original.format, tools: original.tools, system: original.system, - variant: original.variant, }) for (const part of replay.parts) { if (part.type === "compaction") continue diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 7f60c02b1..c9a62c864 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -127,7 +127,9 @@ export namespace LLM { } const variant = - !input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {} + !input.small && input.model.variants && input.user.model.variant + ? input.model.variants[input.user.model.variant] + : {} const base = input.small ? ProviderTransform.smallOptions(input.model) : ProviderTransform.options({ diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index eb3951985..e8aab62d8 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -371,10 +371,10 @@ export namespace MessageV2 { model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod, + variant: z.string().optional(), }), system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), - variant: z.string().optional(), }).meta({ ref: "UserMessage", }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 24996c8d4..b91dfded5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -569,7 +569,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the sessionID, mode: task.agent, agent: task.agent, - variant: lastUser.variant, + variant: lastUser.model.variant, path: { cwd: ctx.directory, root: ctx.worktree }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, @@ -967,17 +967,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the : undefined const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) - const info: MessageV2.Info = { + const info: MessageV2.User = { id: input.messageID ?? MessageID.ascending(), role: "user", sessionID: input.sessionID, time: { created: Date.now() }, tools: input.tools, agent: ag.name, - model, + model: { + providerID: model.providerID, + modelID: model.modelID, + variant, + }, system: input.system, format: input.format, - variant, } yield* Effect.addFinalizer(() => @@ -1436,7 +1439,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the role: "assistant", mode: agent.name, agent: agent.name, - variant: lastUser.variant, + variant: lastUser.model.variant, path: { cwd: ctx.directory, root: ctx.worktree }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 946797da5..1fa2e61eb 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -342,8 +342,7 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, - variant: "high", + model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" }, } satisfies MessageV2.User const stream = await LLM.stream({ @@ -716,8 +715,7 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderID.make("openai"), modelID: resolved.id }, - variant: "high", + model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" }, } satisfies MessageV2.User const stream = await LLM.stream({ diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 51d2e1194..bf7b99ef2 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -410,7 +410,7 @@ describe("session.prompt agent variant", () => { parts: [{ type: "text", text: "hello" }], }) if (other.info.role !== "user") throw new Error("expected user message") - expect(other.info.variant).toBeUndefined() + expect(other.info.model.variant).toBeUndefined() const match = await SessionPrompt.prompt({ sessionID: session.id, @@ -419,8 +419,12 @@ describe("session.prompt agent variant", () => { parts: [{ type: "text", text: "hello again" }], }) if (match.info.role !== "user") throw new Error("expected user message") - expect(match.info.model).toEqual({ providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-5.2") }) - expect(match.info.variant).toBe("xhigh") + expect(match.info.model).toEqual({ + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-5.2"), + variant: "xhigh", + }) + expect(match.info.model.variant).toBe("xhigh") const override = await SessionPrompt.prompt({ sessionID: session.id, @@ -430,7 +434,7 @@ describe("session.prompt agent variant", () => { parts: [{ type: "text", text: "hello third" }], }) if (override.info.role !== "user") throw new Error("expected user message") - expect(override.info.variant).toBe("high") + expect(override.info.model.variant).toBe("high") await Session.remove(session.id) }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 548ab8363..fc1616c4f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -548,12 +548,12 @@ export type UserMessage = { model: { providerID: string modelID: string + variant?: string } system?: string tools?: { [key: string]: boolean } - variant?: string } export type AssistantMessage = { From 6a5aae9a84bd1aef360a84fc50f41e3a82b7baa4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 7 Apr 2026 14:14:07 +0000 Subject: [PATCH 03/23] chore: generate --- packages/sdk/openapi.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index e21c48e89..5007e78e8 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8717,6 +8717,9 @@ }, "modelID": { "type": "string" + }, + "variant": { + "type": "string" } }, "required": ["providerID", "modelID"] @@ -8732,9 +8735,6 @@ "additionalProperties": { "type": "boolean" } - }, - "variant": { - "type": "string" } }, "required": ["id", "sessionID", "role", "time", "agent", "model"] From 65318a80f7a3320ba77b749241f8de997dc65c82 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:02:37 -0500 Subject: [PATCH 04/23] chore: update web stats --- packages/console/app/src/config.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index 19e331c39..6f11bfa02 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -9,8 +9,8 @@ export const config = { github: { repoUrl: "https://github.com/anomalyco/opencode", starsFormatted: { - compact: "120K", - full: "120,000", + compact: "140K", + full: "140,000", }, }, @@ -22,8 +22,8 @@ export const config = { // Static stats (used on landing page) stats: { - contributors: "800", - commits: "10,000", - monthlyUsers: "5M", + contributors: "850", + commits: "11,000", + monthlyUsers: "6.5M", }, } as const From ec8b9810b4231cd6a5c69ccd930b6c50999fc997 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:06:23 -0500 Subject: [PATCH 05/23] feat(app): better subagent experience (#20708) --- .../session/session-child-navigation.spec.ts | 29 +- packages/app/src/i18n/en.ts | 2 + packages/app/src/index.css | 40 ++ packages/app/src/pages/layout.tsx | 17 - packages/app/src/pages/layout/helpers.test.ts | 14 + packages/app/src/pages/layout/helpers.ts | 21 +- .../app/src/pages/layout/sidebar-items.tsx | 296 ++++-------- .../app/src/pages/layout/sidebar-project.tsx | 27 +- .../src/pages/layout/sidebar-workspace.tsx | 26 +- packages/app/src/pages/session.tsx | 14 +- .../composer/session-composer-region.tsx | 61 ++- .../src/pages/session/message-timeline.tsx | 421 +++++++++++------- packages/app/src/utils/agent.ts | 23 +- packages/ui/src/components/basic-tool.css | 92 ++++ packages/ui/src/components/basic-tool.tsx | 142 +++--- packages/ui/src/components/collapsible.css | 5 + packages/ui/src/components/message-part.tsx | 150 +++++-- packages/ui/src/context/data.tsx | 4 + 18 files changed, 832 insertions(+), 552 deletions(-) diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts index 34a1a9e2e..c9fad1af8 100644 --- a/packages/app/e2e/session/session-child-navigation.spec.ts +++ b/packages/app/e2e/session/session-child-navigation.spec.ts @@ -1,7 +1,6 @@ import { seedSessionTask, withSession } from "../actions" import { test, expect } from "../fixtures" import { inputMatch } from "../prompt/mock" -import { promptSelector } from "../selectors" test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => { test.setTimeout(120_000) @@ -30,15 +29,33 @@ test("task tool child-session link does not trigger stale show errors", async ({ await project.gotoSession(session.id) - const link = page - .locator("a.subagent-link") + const header = page.locator("[data-session-title]") + await expect(header.getByRole("button", { name: "More options" })).toBeVisible({ timeout: 30_000 }) + + const card = page + .locator('[data-component="task-tool-card"]') .filter({ hasText: /open child session/i }) .first() - await expect(link).toBeVisible({ timeout: 30_000 }) - await link.click() + await expect(card).toBeVisible({ timeout: 30_000 }) + await card.click() await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) - await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 }) + await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(session.title) + await expect(header.locator('[data-slot="session-title-child"]')).toHaveText(taskInput.description) + await expect(header.locator('[data-slot="session-title-separator"]')).toHaveText("/") + await expect + .poll( + () => + header.locator('[data-slot="session-title-separator"]').evaluate((el) => ({ + left: getComputedStyle(el).paddingLeft, + right: getComputedStyle(el).paddingRight, + })), + { timeout: 30_000 }, + ) + .toEqual({ left: "8px", right: "8px" }) + await expect(header.getByRole("button", { name: "More options" })).toHaveCount(0) + await expect(page.getByText("Subagent sessions cannot be prompted.")).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole("button", { name: "Back to main session." })).toBeVisible({ timeout: 30_000 }) await expect.poll(() => errs, { timeout: 5_000 }).toEqual([]) }) } finally { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index ace0efeb8..c6bcc37b1 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -238,6 +238,8 @@ export const dict = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "prompt.mode.shell.exit": "esc to exit", + "session.child.promptDisabled": "Subagent sessions cannot be prompted.", + "session.child.backToParent": "Back to main session.", "prompt.example.1": "Fix a TODO in the codebase", "prompt.example.2": "What is the tech stack of this project?", diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 9e231e2d2..629ac80a8 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -1,6 +1,46 @@ @import "@opencode-ai/ui/styles/tailwind"; @layer components { + @keyframes session-progress-whip { + 0% { + clip-path: inset(0 100% 0 0 round 999px); + animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); + } + + 48% { + clip-path: inset(0 0 0 0 round 999px); + animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1); + } + + 100% { + clip-path: inset(0 0 0 100% round 999px); + } + } + + [data-component="session-progress"] { + position: absolute; + inset: 0 0 auto; + height: 2px; + overflow: hidden; + pointer-events: none; + opacity: 1; + transition: opacity 220ms ease-out; + } + + [data-component="session-progress"][data-state="hiding"] { + opacity: 0; + } + + [data-component="session-progress-bar"] { + width: 100%; + height: 100%; + border-radius: 999px; + background: var(--session-progress-color); + clip-path: inset(0 100% 0 0 round 999px); + animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite; + will-change: clip-path; + } + [data-component="getting-started"] { container-type: inline-size; container-name: getting-started; diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 79b9abd33..f402f4bc0 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -150,7 +150,6 @@ export default function Layout(props: ParentProps) { const [state, setState] = createStore({ autoselect: !initialDirectory, busyWorkspaces: {} as Record, - hoverSession: undefined as string | undefined, hoverProject: undefined as string | undefined, scrollSessionKey: undefined as string | undefined, nav: undefined as HTMLElement | undefined, @@ -194,7 +193,6 @@ export default function Layout(props: ParentProps) { onActivate: (directory) => { globalSync.child(directory) setState("hoverProject", directory) - setState("hoverSession", undefined) }, }) @@ -231,7 +229,6 @@ export default function Layout(props: ParentProps) { aim.reset() } const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined)) - const setHoverSession = (id: string | undefined) => setState("hoverSession", id) const disarm = () => { if (navLeave.current === undefined) return @@ -241,7 +238,6 @@ export default function Layout(props: ParentProps) { const reset = () => { disarm() - setState("hoverSession", undefined) setHoverProject(undefined) } @@ -252,7 +248,6 @@ export default function Layout(props: ParentProps) { navLeave.current = window.setTimeout(() => { navLeave.current = undefined setHoverProject(undefined) - setState("hoverSession", undefined) }, 300) } @@ -1972,9 +1967,6 @@ export default function Layout(props: ParentProps) { navList: currentSessions, sidebarExpanded, sidebarHovering, - nav: () => state.nav, - hoverSession: () => state.hoverSession, - setHoverSession, clearHoverProjectSoon, prefetchSession, archiveSession, @@ -2003,7 +1995,6 @@ export default function Layout(props: ParentProps) { sidebarOpened: () => layout.sidebar.opened(), sidebarHovering, hoverProject: () => state.hoverProject, - nav: () => state.nav, onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event), onProjectMouseLeave: (worktree) => aim.leave(worktree), onProjectFocus: (worktree) => aim.activate(worktree), @@ -2022,15 +2013,10 @@ export default function Layout(props: ParentProps) { sessionProps: { navList: currentSessions, sidebarExpanded, - sidebarHovering, - nav: () => state.nav, - hoverSession: () => state.hoverSession, - setHoverSession, clearHoverProjectSoon, prefetchSession, archiveSession, }, - setHoverSession, } const SidebarPanel = (panelProps: { @@ -2041,7 +2027,6 @@ export default function Layout(props: ParentProps) { const project = panelProps.project const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened()) - const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened()) const empty = createMemo(() => !params.dir && layout.projects.list().length === 0) const projectName = createMemo(() => { const item = project() @@ -2243,7 +2228,6 @@ export default function Layout(props: ParentProps) { project={project()!} sortNow={sortNow} mobile={panelProps.mobile} - popover={popover()} /> @@ -2288,7 +2272,6 @@ export default function Layout(props: ParentProps) { project={project()!} sortNow={sortNow} mobile={panelProps.mobile} - popover={popover()} /> )} diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 1fe52d47a..988332ab7 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -8,6 +8,7 @@ import { } from "./deep-links" import { type Session } from "@opencode-ai/sdk/v2/client" import { + childSessionOnPath, displayName, effectiveWorkspaceOrder, errorMessage, @@ -198,6 +199,19 @@ describe("layout workspace helpers", () => { expect(result?.id).toBe("root") }) + test("finds the direct child on the active session path", () => { + const list = [ + session({ id: "root", directory: "/workspace" }), + session({ id: "child", directory: "/workspace", parentID: "root" }), + session({ id: "leaf", directory: "/workspace", parentID: "child" }), + ] + + expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child") + expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf") + expect(childSessionOnPath(list, "root", "root")).toBeUndefined() + expect(childSessionOnPath(list, "root", "other")).toBeUndefined() + }) + test("formats fallback project display name", () => { expect(displayName({ worktree: "/tmp/app" })).toBe("app") expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App") diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 226098c1c..48158debb 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -46,18 +46,17 @@ export function hasProjectPermissions( return Object.values(request ?? {}).some((list) => list?.some(include)) } -export const childMapByParent = (sessions: Session[] | undefined) => { - const map = new Map() - for (const session of sessions ?? []) { - if (!session.parentID) continue - const existing = map.get(session.parentID) - if (existing) { - existing.push(session.id) - continue - } - map.set(session.parentID, [session.id]) +export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => { + if (!activeID || activeID === rootID) return + const map = new Map((sessions ?? []).map((session) => [session.id, session])) + let id = activeID + + while (id) { + const session = map.get(id) + if (!session?.parentID) return + if (session.parentID === rootID) return session + id = session.parentID } - return map } export const displayName = (project: { name?: string; worktree: string }) => diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 058bb5a0d..e56accfc8 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -1,15 +1,12 @@ -import type { Message, Session, TextPart, UserMessage } from "@opencode-ai/sdk/v2/client" +import type { Session } from "@opencode-ai/sdk/v2/client" import { Avatar } from "@opencode-ai/ui/avatar" -import { HoverCard } from "@opencode-ai/ui/hover-card" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" -import { MessageNav } from "@opencode-ai/ui/message-nav" import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { base64Encode } from "@opencode-ai/util/encode" import { getFilename } from "@opencode-ai/util/path" -import { A, useNavigate, useParams } from "@solidjs/router" -import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" +import { A, useParams } from "@solidjs/router" +import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" @@ -18,7 +15,7 @@ import { usePermission } from "@/context/permission" import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" import { sessionPermissionRequest } from "../session/composer/session-request-tree" -import { hasProjectPermissions } from "./helpers" +import { childSessionOnPath, hasProjectPermissions } from "./helpers" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" @@ -39,6 +36,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti ) const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) + return (
@@ -73,13 +71,10 @@ export type SessionItemProps = { slug: string mobile?: boolean dense?: boolean - popover?: boolean - children: Map + showTooltip?: boolean + showChild?: boolean + level?: number sidebarExpanded: Accessor - sidebarHovering: Accessor - nav: Accessor - hoverSession: Accessor - setHoverSession: (id: string | undefined) => void clearHoverProjectSoon: () => void prefetchSession: (session: Session, priority?: "high" | "low") => void archiveSession: (session: Session) => Promise @@ -95,116 +90,52 @@ const SessionRow = (props: { hasPermissions: Accessor hasError: Accessor unseenCount: Accessor - setHoverSession: (id: string | undefined) => void clearHoverProjectSoon: () => void sidebarOpened: Accessor - warmHover: () => void warmPress: () => void warmFocus: () => void - cancelHoverPrefetch: () => void -}) => { +}): JSX.Element => { const title = () => sessionTitle(props.session.title) return ( { - props.setHoverSession(undefined) if (props.sidebarOpened()) return props.clearHoverProjectSoon() }} > -
- }> - - - - -
- - -
- - 0}> -
- - -
+ 0}> +
+ + + + + +
+ + +
+ + 0}> +
+ + +
+ {title()}
) } -const SessionHoverPreview = (props: { - mobile?: boolean - nav: Accessor - hoverSession: Accessor - session: Session - sidebarHovering: Accessor - hoverReady: Accessor - hoverMessages: Accessor - language: ReturnType - isActive: Accessor - slug: string - setHoverSession: (id: string | undefined) => void - messageLabel: (message: Message) => string | undefined - onMessageSelect: (message: Message) => void - trigger: JSX.Element -}): JSX.Element => { - let ref: HTMLDivElement | undefined - - return ( - - {props.trigger} -
- } - open={props.hoverSession() === props.session.id} - onOpenChange={(open) => { - if (!open) { - props.setHoverSession(undefined) - return - } - if (!ref?.matches(":hover")) return - props.setHoverSession(props.session.id) - }} - > - {props.language.t("session.messages.loading")}
} - > -
- -
- - - ) -} - export const SessionItem = (props: SessionItemProps): JSX.Element => { const params = useParams() - const navigate = useNavigate() const layout = useLayout() const language = useLanguage() const notification = useNotification() @@ -234,18 +165,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { ) }) - const tint = createMemo(() => { - return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent) + const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)) + const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded())) + const currentChild = createMemo(() => { + if (!props.showChild) return + return childSessionOnPath(sessionStore.session, props.session.id, params.id) }) - const hoverMessages = createMemo(() => - sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"), - ) - const hoverReady = createMemo(() => hoverMessages() !== undefined) - const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded()) - const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) - const isActive = createMemo(() => props.session.id === params.id) - const warm = (span: number, priority: "high" | "low") => { const nav = props.navList?.() const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory) @@ -266,30 +192,6 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { } } - const hoverPrefetch = { - current: undefined as ReturnType | undefined, - } - const cancelHoverPrefetch = () => { - if (hoverPrefetch.current === undefined) return - clearTimeout(hoverPrefetch.current) - hoverPrefetch.current = undefined - } - const scheduleHoverPrefetch = () => { - warm(1, "high") - if (hoverPrefetch.current !== undefined) return - hoverPrefetch.current = setTimeout(() => { - hoverPrefetch.current = undefined - warm(2, "low") - }, 80) - } - - onCleanup(cancelHoverPrefetch) - - const messageLabel = (message: Message) => { - const parts = sessionStore.part[message.id] ?? [] - const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored) - return text?.text - } const item = ( { hasPermissions={hasPermissions} hasError={hasError} unseenCount={unseenCount} - setHoverSession={props.setHoverSession} clearHoverProjectSoon={props.clearHoverProjectSoon} sidebarOpened={layout.sidebar.opened} - warmHover={scheduleHoverPrefetch} warmPress={() => warm(2, "high")} warmFocus={() => warm(2, "high")} - cancelHoverPrefetch={cancelHoverPrefetch} /> ) return ( -
-
-
- - {item} - - } - > - { - if (!isActive()) - layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) + <> +
+
+
+ + {item} + + } + > + {item} + +
- navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) + +
+ > + + { + event.preventDefault() + event.stopPropagation() + void props.archiveSession(props.session) + }} + /> + +
- -
- - { - event.preventDefault() - event.stopPropagation() - void props.archiveSession(props.session) - }} - /> - -
-
+ + {(child) => ( +
+ +
+ )} +
+ ) } @@ -390,7 +280,6 @@ export const NewSessionItem = (props: { dense?: boolean sidebarExpanded: Accessor clearHoverProjectSoon: () => void - setHoverSession: (id: string | undefined) => void }): JSX.Element => { const layout = useLayout() const language = useLanguage() @@ -400,9 +289,8 @@ export const NewSessionItem = (props: { { - props.setHoverSession(undefined) if (layout.sidebar.opened()) return props.clearHoverProjectSoon() }} diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index aff0645dd..7c9ae1aaf 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { base64Encode } from "@opencode-ai/util/encode" import { Button } from "@opencode-ai/ui/button" @@ -11,7 +11,7 @@ import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { useNotification } from "@/context/notification" import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items" -import { childMapByParent, displayName, sortedRootSessions } from "./helpers" +import { displayName, sortedRootSessions } from "./helpers" export type ProjectSidebarContext = { currentDir: Accessor @@ -19,7 +19,6 @@ export type ProjectSidebarContext = { sidebarOpened: Accessor sidebarHovering: Accessor hoverProject: Accessor - nav: Accessor onProjectMouseEnter: (worktree: string, event: MouseEvent) => void onProjectMouseLeave: (worktree: string) => void onProjectFocus: (worktree: string) => void @@ -32,8 +31,7 @@ export type ProjectSidebarContext = { workspacesEnabled: (project: LocalProject) => boolean workspaceIds: (project: LocalProject) => string[] workspaceLabel: (directory: string, branch?: string, projectId?: string) => string - sessionProps: Omit - setHoverSession: (id: string | undefined) => void + sessionProps: Omit } export const ProjectDragOverlay = (props: { @@ -55,7 +53,6 @@ export const ProjectDragOverlay = (props: { const ProjectTile = (props: { project: LocalProject mobile?: boolean - nav: Accessor sidebarHovering: Accessor selected: Accessor active: Accessor @@ -195,9 +192,7 @@ const ProjectPreviewPanel = (props: { workspaces: Accessor label: (directory: string) => string projectSessions: Accessor> - projectChildren: Accessor> workspaceSessions: (directory: string) => ReturnType - workspaceChildren: (directory: string) => Map ctx: ProjectSidebarContext language: ReturnType }): JSX.Element => ( @@ -218,9 +213,8 @@ const ProjectPreviewPanel = (props: { list={props.projectSessions()} slug={base64Encode(props.project.worktree)} dense + showTooltip mobile={props.mobile} - popover={false} - children={props.projectChildren()} /> )} @@ -229,7 +223,6 @@ const ProjectPreviewPanel = (props: { {(directory) => { const sessions = createMemo(() => props.workspaceSessions(directory)) - const children = createMemo(() => props.workspaceChildren(directory)) return (
@@ -246,9 +239,8 @@ const ProjectPreviewPanel = (props: { list={sessions()} slug={base64Encode(directory)} dense + showTooltip mobile={props.mobile} - popover={false} - children={children()} /> )} @@ -310,20 +302,14 @@ export const SortableProject = (props: { const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) - const projectChildren = createMemo(() => childMapByParent(projectStore().session)) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) return sortedRootSessions(data, props.sortNow()) } - const workspaceChildren = (directory: string) => { - const [data] = globalSync.child(directory, { bootstrap: false }) - return childMapByParent(data.session) - } const tile = () => ( diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 3bf00ea42..68e36ff77 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" -import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers" +import { sortedRootSessions, workspaceKey } from "./helpers" type InlineEditorComponent = (props: { id: string @@ -35,9 +35,6 @@ export type WorkspaceSidebarContext = { navList: Accessor sidebarExpanded: Accessor sidebarHovering: Accessor - nav: Accessor - hoverSession: Accessor - setHoverSession: (id: string | undefined) => void clearHoverProjectSoon: () => void prefetchSession: (session: Session, priority?: "high" | "low") => void archiveSession: (session: Session) => Promise @@ -152,7 +149,6 @@ const WorkspaceActions = (props: { showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"] showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"] root: string - setHoverSession: WorkspaceSidebarContext["setHoverSession"] clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"] navigateToNewSession: () => void }): JSX.Element => ( @@ -226,7 +222,6 @@ const WorkspaceActions = (props: { onClick={(event) => { event.preventDefault() event.stopPropagation() - props.setHoverSession(undefined) props.clearHoverProjectSoon() props.navigateToNewSession() }} @@ -239,12 +234,10 @@ const WorkspaceActions = (props: { const WorkspaceSessionList = (props: { slug: Accessor mobile?: boolean - popover?: boolean ctx: WorkspaceSidebarContext showNew: Accessor loading: Accessor sessions: Accessor - children: Accessor> hasMore: Accessor loadMore: () => Promise language: ReturnType @@ -256,7 +249,6 @@ const WorkspaceSessionList = (props: { mobile={props.mobile} sidebarExpanded={props.ctx.sidebarExpanded} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} - setHoverSession={props.ctx.setHoverSession} /> @@ -270,13 +262,8 @@ const WorkspaceSessionList = (props: { navList={props.ctx.navList} slug={props.slug()} mobile={props.mobile} - popover={props.popover} - children={props.children()} + showChild sidebarExpanded={props.ctx.sidebarExpanded} - sidebarHovering={props.ctx.sidebarHovering} - nav={props.ctx.nav} - hoverSession={props.ctx.hoverSession} - setHoverSession={props.ctx.setHoverSession} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} prefetchSession={props.ctx.prefetchSession} archiveSession={props.ctx.archiveSession} @@ -307,7 +294,6 @@ export const SortableWorkspace = (props: { project: LocalProject sortNow: Accessor mobile?: boolean - popover?: boolean }): JSX.Element => { const navigate = useNavigate() const params = useParams() @@ -321,7 +307,6 @@ export const SortableWorkspace = (props: { }) const slug = createMemo(() => base64Encode(props.directory)) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) - const children = createMemo(() => childMapByParent(workspaceStore.session)) const local = createMemo(() => props.directory === props.project.worktree) const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) const workspaceValue = createMemo(() => { @@ -428,7 +413,6 @@ export const SortableWorkspace = (props: { showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} root={props.project.worktree} - setHoverSession={props.ctx.setHoverSession} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} navigateToNewSession={() => navigate(`/${slug()}/session`)} /> @@ -440,12 +424,10 @@ export const SortableWorkspace = (props: { mobile?: boolean - popover?: boolean }): JSX.Element => { const globalSync = useGlobalSync() const language = useLanguage() @@ -471,7 +452,6 @@ export const LocalWorkspace = (props: { }) const slug = createMemo(() => base64Encode(props.project.worktree)) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) - const children = createMemo(() => childMapByParent(workspace().store.session)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const count = createMemo(() => sessions()?.length ?? 0) const loading = createMemo(() => !booted() && count() === 0) @@ -489,12 +469,10 @@ export const LocalWorkspace = (props: { false} loading={loading} sessions={sessions} - children={children} hasMore={hasMore} loadMore={loadMore} language={language} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index a81df9dd2..0c6764726 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -429,6 +429,7 @@ export default function Page() { } const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const isChildSession = createMemo(() => !!info()?.parentID) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const hasSessionReview = createMemo(() => sessionCount() > 0) @@ -1058,7 +1059,7 @@ export default function Page() { } if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { - if (composer.blocked()) return + if (composer.blocked() || isChildSession()) return inputRef?.focus() } } @@ -1127,7 +1128,10 @@ export default function Page() { setFileTreeTab("all") } - const focusInput = () => inputRef?.focus() + const focusInput = () => { + if (isChildSession()) return + inputRef?.focus() + } useSessionCommands({ navigateMessageByOffset, @@ -1658,7 +1662,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() + return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession() }) const followupText = (item: FollowupDraft) => { @@ -1690,6 +1694,7 @@ export default function Page() { const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) }))) const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { + if (sync.session.get(sessionID)?.parentID) return Promise.resolve() const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) if (!item) return Promise.resolve() if (followupBusy(sessionID)) return Promise.resolve() @@ -1820,6 +1825,7 @@ export default function Page() { if (followupBusy(sessionID)) return if (followup.failed[sessionID] === item.id) return if (followup.paused[sessionID]) return + if (isChildSession()) return if (composer.blocked()) return if (busy(sessionID)) return @@ -2001,7 +2007,7 @@ export default function Page() { }} onResponseSubmit={resumeScroll} followup={ - params.id + params.id && !isChildSession() ? { queue: queueEnabled, items: followupDock(), diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 372adef96..60447566e 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,9 +1,11 @@ import { Show, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" +import { useNavigate } from "@solidjs/router" import { useSpring } from "@opencode-ai/ui/motion-spring" import { PromptInput } from "@/components/prompt-input" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" +import { useSync } from "@/context/sync" import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import { useSessionKey } from "@/pages/session/session-layout" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" @@ -43,11 +45,17 @@ export function SessionComposerRegion(props: { } setPromptDockRef: (el: HTMLDivElement) => void }) { + const navigate = useNavigate() const prompt = usePrompt() const language = useLanguage() const route = useSessionKey() + const sync = useSync() const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt) + const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined)) + const parentID = createMemo(() => info()?.parentID) + const child = createMemo(() => !!parentID()) + const showComposer = createMemo(() => !props.state.blocked() || child()) const previewPrompt = () => prompt @@ -113,6 +121,12 @@ export function SessionComposerRegion(props: { const lift = createMemo(() => (rolled() ? 18 : 36 * value())) const full = createMemo(() => Math.max(78, store.height)) + const openParent = () => { + const id = parentID() + if (!id) return + navigate(`/${route.params.dir}/session/${id}`) + } + createEffect(() => { const el = store.body if (!el) return @@ -156,7 +170,7 @@ export function SessionComposerRegion(props: { )} - + - + + + + } + > +
+ {language.t("session.child.promptDisabled")} + + + +
+
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index bc211303a..fe6447c2e 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -21,6 +21,7 @@ import { Popover as KobaltePopover } from "@kobalte/core/popover" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createResizeObserver } from "@solid-primitives/resize-observer" import { useLanguage } from "@/context/language" import { useSessionKey } from "@/pages/session/session-layout" import { useGlobalSDK } from "@/context/global-sdk" @@ -68,6 +69,16 @@ const messageComments = (parts: Part[]): MessageComment[] => ] }) +const taskDescription = (part: Part, sessionID: string) => { + if (part.type !== "tool" || part.tool !== "task") return + const metadata = "metadata" in part.state ? part.state.metadata : undefined + if (metadata?.sessionId !== sessionID) return + const value = part.state.input?.description + if (typeof value === "string" && value) return value +} + +const pace = (width: number) => Math.round(Math.max(1200, Math.min(3200, (Math.max(width, 360) * 2000) / 900))) + const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const current = target instanceof Element ? target : undefined const nested = current?.closest("[data-scrollable]") @@ -295,6 +306,32 @@ export function MessageTimeline(props: { 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() + if (!id) return + return sync.session.get(id) + }) + const parentMessages = createMemo(() => { + const id = parentID() + if (!id) return emptyMessages + return sync.data.message[id] ?? emptyMessages + }) + const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new")) + const childTaskDescription = createMemo(() => { + const id = sessionID() + if (!id) return + return parentMessages() + .flatMap((message) => sync.data.part[message.id] ?? []) + .map((part) => taskDescription(part, id)) + .findLast((value): value is string => !!value) + }) + const childTitle = createMemo(() => { + if (!parentID()) return titleLabel() ?? "" + if (childTaskDescription()) return childTaskDescription() + const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "") + if (value) return value + return language.t("command.session.new") + }) const showHeader = createMemo(() => !!(titleValue() || parentID())) const stageCfg = { init: 1, batch: 3 } const staging = createTimelineStaging({ @@ -317,8 +354,20 @@ export function MessageTimeline(props: { open: false, dismiss: null as "escape" | "outside" | null, }) + const [bar, setBar] = createStore({ + ms: pace(640), + }) let more: HTMLButtonElement | undefined + let head: HTMLDivElement | undefined + + createResizeObserver( + () => head, + () => { + if (!head || head.clientWidth <= 0) return + setBar("ms", pace(head.clientWidth)) + }, + ) const viewShare = () => { const url = shareUrl() @@ -398,8 +447,20 @@ export function MessageTimeline(props: { ), ) + createEffect( + on( + () => [parentID(), childTaskDescription()] as const, + ([id, description]) => { + if (!id || description) return + if (sync.data.message[id] !== undefined) return + void sync.session.sync(id) + }, + { defer: true }, + ), + ) + const openTitleEditor = () => { - if (!sessionID()) return + if (!sessionID() || parentID()) return setTitle({ editing: true, draft: titleLabel() ?? "" }) requestAnimationFrame(() => { titleRef?.focus() @@ -646,27 +707,53 @@ export function MessageTimeline(props: {
{ + head = el + setBar("ms", pace(el.clientWidth)) + }} data-session-title classList={{ "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, + relative: true, "w-full": true, "pb-4": true, "pl-2 pr-3 md:pl-4 md:pr-3": true, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, }} > + +