From 02adfbb84389c6d8d3413277760ea6a83cbf0417 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 7 Apr 2026 15:45:22 +0530 Subject: [PATCH 01/68] feat(app): show full names on composer attachment chips (#21306) --- .../prompt-input/image-attachments.tsx | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx index 835fddc30710..dd8138e5a4f2 100644 --- a/packages/app/src/components/prompt-input/image-attachments.tsx +++ b/packages/app/src/components/prompt-input/image-attachments.tsx @@ -1,5 +1,6 @@ import { Component, For, Show } from "solid-js" import { Icon } from "@opencode-ai/ui/icon" +import { Tooltip } from "@opencode-ai/ui/tooltip" import type { ImageAttachmentPart } from "@/context/prompt" type PromptImageAttachmentsProps = { @@ -22,34 +23,36 @@ export const PromptImageAttachments: Component = (p
{(attachment) => ( -
- - -
- } - > - {attachment.filename} props.onOpen(attachment)} - /> - - -
- {attachment.filename} + +
+ + +
+ } + > + {attachment.filename} props.onOpen(attachment)} + /> + + +
+ {attachment.filename} +
-
+ )} From f8847f22d45dff659e8c790855755930d871af29 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 7 Apr 2026 16:17:53 +0530 Subject: [PATCH 02/68] style(app): redesign jump-to-bottom button per figma spec (#21313) --- packages/app/src/pages/session/message-timeline.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index cbabbda72d8e..bc211303a6ac 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -577,10 +577,18 @@ export function MessageTimeline(props: { }} > Date: Tue, 7 Apr 2026 16:30:13 +0530 Subject: [PATCH 03/68] Move auto-accept permissions to settings (#21308) --- packages/app/e2e/prompt/prompt-shell.spec.ts | 15 ++++--- .../e2e/session/session-composer-dock.spec.ts | 21 +++++----- packages/app/src/components/prompt-input.tsx | 39 +------------------ .../app/src/components/settings-general.tsx | 39 +++++++++++++++++++ 4 files changed, 59 insertions(+), 55 deletions(-) diff --git a/packages/app/e2e/prompt/prompt-shell.spec.ts b/packages/app/e2e/prompt/prompt-shell.spec.ts index 28fa02dcd328..81af4cb1bc5c 100644 --- a/packages/app/e2e/prompt/prompt-shell.spec.ts +++ b/packages/app/e2e/prompt/prompt-shell.spec.ts @@ -1,6 +1,6 @@ import type { ToolPart } from "@opencode-ai/sdk/v2/client" import { test, expect } from "../fixtures" -import { withSession } from "../actions" +import { closeDialog, openSettings, withSession } from "../actions" import { promptModelSelector, promptSelector, promptVariantSelector } from "../selectors" const isBash = (part: unknown): part is ToolPart => { @@ -19,12 +19,15 @@ test("shell mode runs a command in the project directory", async ({ page, projec await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => { project.trackSession(session.id) await project.gotoSession(session.id) - const button = page.locator('[data-action="prompt-permissions"]').first() - await expect(button).toBeVisible() - if ((await button.getAttribute("aria-pressed")) !== "true") { - await button.click() - await expect(button).toHaveAttribute("aria-pressed", "true") + const dialog = await openSettings(page) + const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first() + const input = toggle.locator('[data-slot="switch-input"]').first() + await expect(toggle).toBeVisible() + if ((await input.getAttribute("aria-checked")) !== "true") { + await toggle.locator('[data-slot="switch-control"]').click() + await expect(input).toHaveAttribute("aria-checked", "true") } + await closeDialog(page, dialog) await project.shell(cmd) await expect diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 8eeac5b1a18a..ecacea83dcb8 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -5,7 +5,7 @@ import { type ComposerProbeState, type ComposerWindow, } from "../../src/testing/session-composer" -import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions" +import { cleanupSession, clearSessionDockSeed, closeDialog, openSettings, seedSessionQuestion } from "../actions" import { permissionDockSelector, promptSelector, @@ -65,12 +65,14 @@ async function clearPermissionDock(page: any, label: RegExp) { } async function setAutoAccept(page: any, enabled: boolean) { - const button = page.locator('[data-action="prompt-permissions"]').first() - await expect(button).toBeVisible() - const pressed = (await button.getAttribute("aria-pressed")) === "true" - if (pressed === enabled) return - await button.click() - await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false") + const dialog = await openSettings(page) + const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first() + const input = toggle.locator('[data-slot="switch-input"]').first() + await expect(toggle).toBeVisible() + const checked = (await input.getAttribute("aria-checked")) === "true" + if (checked !== enabled) await toggle.locator('[data-slot="switch-control"]').click() + await expect(input).toHaveAttribute("aria-checked", enabled ? "true" : "false") + await closeDialog(page, dialog) } async function expectQuestionBlocked(page: any) { @@ -277,6 +279,7 @@ test("default dock shows prompt input", async ({ page, project }) => { await expect(page.locator(sessionComposerDockSelector)).toBeVisible() await expect(page.locator(promptSelector)).toBeVisible() + await expect(page.locator('[data-action="prompt-permissions"]')).toHaveCount(0) await expect(page.locator(questionDockSelector)).toHaveCount(0) await expect(page.locator(permissionDockSelector)).toHaveCount(0) @@ -290,10 +293,6 @@ test("default dock shows prompt input", async ({ page, project }) => { test("auto-accept toggle works before first submit", async ({ page, project }) => { await project.open() - const button = page.locator('[data-action="prompt-permissions"]').first() - await expect(button).toBeVisible() - await expect(button).toHaveAttribute("aria-pressed", "false") - await setAutoAccept(page, true) await setAutoAccept(page, false) }) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index e9049ae7e23e..eedbc91cfdbe 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1079,17 +1079,6 @@ export const PromptInput: Component = (props) => { if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) return permission.isAutoAccepting(id, sdk.directory) }) - const acceptLabel = createMemo(() => - language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"), - ) - const toggleAccept = () => { - if (!params.id) { - permission.toggleAutoAcceptDirectory(sdk.directory) - return - } - - permission.toggleAutoAccept(params.id, sdk.directory) - } const { abort, handleSubmit } = createPromptSubmit({ info, @@ -1333,11 +1322,7 @@ export const PromptInput: Component = (props) => { onMouseDown={(e) => { const target = e.target if (!(target instanceof HTMLElement)) return - if ( - target.closest( - '[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]', - ) - ) { + if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { return } editorRef?.focus() @@ -1597,28 +1582,6 @@ export const PromptInput: Component = (props) => { - - - diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 8a4d498866aa..b4ac061df494 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -8,7 +8,9 @@ import { TextField } from "@opencode-ai/ui/text-field" import { Tooltip } from "@opencode-ai/ui/tooltip" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { showToast } from "@opencode-ai/ui/toast" +import { useParams } from "@solidjs/router" import { useLanguage } from "@/context/language" +import { usePermission } from "@/context/permission" import { usePlatform } from "@/context/platform" import { monoDefault, @@ -19,6 +21,7 @@ import { sansInput, useSettings, } from "@/context/settings" +import { decode64 } from "@/utils/base64" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" import { SettingsList } from "./settings-list" @@ -64,7 +67,9 @@ const playDemoSound = (id: string | undefined) => { export const SettingsGeneral: Component = () => { const theme = useTheme() const language = useLanguage() + const permission = usePermission() const platform = usePlatform() + const params = useParams() const settings = useSettings() onMount(() => { @@ -76,6 +81,31 @@ export const SettingsGeneral: Component = () => { }) const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") + const dir = createMemo(() => decode64(params.dir)) + const accepting = createMemo(() => { + const value = dir() + if (!value) return false + if (!params.id) return permission.isAutoAcceptingDirectory(value) + return permission.isAutoAccepting(params.id, value) + }) + + const toggleAccept = (checked: boolean) => { + const value = dir() + if (!value) return + + if (!params.id) { + if (permission.isAutoAcceptingDirectory(value) === checked) return + permission.toggleAutoAcceptDirectory(value) + return + } + + if (checked) { + permission.enableAutoAccept(params.id, value) + return + } + + permission.disableAutoAccept(params.id, value) + } const check = () => { if (!platform.checkUpdate) return @@ -201,6 +231,15 @@ export const SettingsGeneral: Component = () => { /> + +
+ +
+
+ Date: Tue, 7 Apr 2026 10:08:57 -0400 Subject: [PATCH 04/68] 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 22652f2daa50..8925f37d5ab7 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 47fee05cf015..0d8cf61cfa8d 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 66b98069851f..9de413e60b5a 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 2c4a09f71186..3343192c19ba 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 6b842639add6..b77ee3c5bf57 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 6b842639add6..b77ee3c5bf57 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 6b842639add6..b77ee3c5bf57 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 6b842639add6..b77ee3c5bf57 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 6b842639add6..b77ee3c5bf57 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 c9e567997bf9..2a40a9f3c93f 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 a9702a05a37c27169b1630857179e34f785dc772 Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 7 Apr 2026 10:12:53 -0400 Subject: [PATCH 05/68] 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 b0166c43a80f..03bece2e311a 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 06b6c1e35108..2a3a3d0e9917 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 84a613c0d285..1633607de4b0 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 bbf4fc5ec46d..b023e8ddc177 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 319db805d25c..2ab293b8fb3a 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 2fef184f5799..045d730c9ef4 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 3158393f1145..bbdce9fd7472 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 7f60c02b1d9f..c9a62c8645e0 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 eb39519854cb..e8aab62d8423 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 24996c8d4b29..b91dfded5e6b 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 946797da50e2..1fa2e61eb241 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 51d2e11941ae..bf7b99ef2e23 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 548ab8363e2c..fc1616c4fd6f 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 350e5ab52a210c345190c4ff6519398ac4cd6791 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 7 Apr 2026 14:14:07 +0000 Subject: [PATCH 06/68] 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 e21c48e89a3a..5007e78e8b73 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 c3dc1eb88539acf88ef8330cddb48a4ea892c618 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 07/68] 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 19e331c39aac..6f11bfa02876 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 8d51cf363722cf0865fdf76b04170fd271ef8d21 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 08/68] 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 | 419 +++++++++++------- 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, 831 insertions(+), 551 deletions(-) diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts index 34a1a9e2e745..c9fad1af8532 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 ace0efeb8714..c6bcc37b116f 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 9e231e2d2858..629ac80a8698 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 79b9abd33284..f402f4bc04df 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 1fe52d47a0a6..988332ab7ce1 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 226098c1cd66..48158debba1d 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 058bb5a0dbed..e56accfc8353 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}> -
- - -
- {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")}
} - > -
- + 0}> +
+ + + + + +
+ + +
+ + 0}> +
+ +
- + {title()} + ) } 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 aff0645dd894..7c9ae1aafba6 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 3bf00ea424d6..68e36ff77aec 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 a81df9dd2779..0c67647261f7 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 372adef96af6..60447566ed01 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 bc211303a6ac..fe6447c2e8c8 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, }} > + +