From 3000aaafae4370c6c48a4fe14d3ea73bc60ebd6d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 12 Nov 2026 23:03:50 -0500 Subject: [PATCH 1/5] feat(app): add a paced queue of dismissible tips --- packages/app/src/new-session/view.tsx | 69 +++++++++++++------ packages/app/src/runtime/i18n/en.ts | 1 + packages/app/src/tips/model.ts | 66 ++++++++++++++++++ packages/app/src/tips/queue.test.ts | 30 ++++++++ packages/app/src/tips/queue.ts | 35 ++++++++++ .../client/src/promise/generated/types.ts | 2 + packages/protocol/openapi.json | 17 ++++- packages/schema/src/config/flag.ts | 1 + packages/schema/src/location.ts | 8 +-- packages/server/src/handlers/location.ts | 5 +- 10 files changed, 207 insertions(+), 27 deletions(-) create mode 100644 packages/app/src/tips/model.ts create mode 100644 packages/app/src/tips/queue.test.ts create mode 100644 packages/app/src/tips/queue.ts diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index 107df2cb2403..763a650d98e6 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -31,6 +31,8 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout" import { Persist, persisted } from "@/runtime/persistence/storage" import { Persistence } from "@/runtime/persistence/schema" import { useSettings } from "@/settings/model" +import { createTipQueue } from "@/tips/model" +import type { Tip } from "@/tips/queue" import type { NewSessionWorkspaceController } from "./workspace/controller" import { CHECKOUT_PLACEHOLDER_DELAY_MS, @@ -182,6 +184,7 @@ export function NewSessionView(props: { function NewSessionTips(props: { selection: ComposerModel["model"]["selection"]; onDone: () => void }) { const language = useLanguage() + const command = useCommand() const dialog = useDialog() const sdk = useWorkspaceLocation() const providers = useProviders(() => sdk().directory) @@ -199,27 +202,46 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"]; Date.now() - providerState.dismissedAt >= providerTipDismissalDuration, ) - const tip = providerVisible + const provider: Tip = { + id: "provider", + text: "home.providerTip", + when: () => providers.anyConnection() === false, + action: async () => { + const { DialogConnectProvider } = await import("@/providers/connect/dialog") + void dialog.show(() => ( + + )) + }, + } + const enabled = () => sdk().current?.experimental?.tips_queue === true + const queue = createTipQueue( + [ + provider, + { id: "session-search", text: "home.sessionSearchTip", action: () => command.trigger("sessions.search") }, + ], + () => enabled() && providerReady() && providers.anyConnection() !== undefined, + () => providerState.dismissedAt, + ) + const tip = () => (enabled() ? queue.tip() : sdk().current && providerVisible() ? provider : undefined) + // Keep the departing tip's contents for its exit animation. + const displayed = createMemo((previous) => tip() ?? previous, provider) const [ref, setRef] = createSignal() const presence = createPresence({ - show: tip, + show: () => !!tip(), element: () => ref() ?? null, }) const open = () => { - if (!tip()) return - - void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => { - void dialog.show(() => ( - - )) - }) + const action = tip()?.action + if (!action) return + if (enabled()) queue.dismiss() + action() } const dismiss = () => { if (!tip()) return - + if (enabled()) return queue.dismiss() setProviderState("dismissedAt", Date.now()) } @@ -229,20 +251,27 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"];
- + + boolean, providerDismissal: () => number) { + const [history, setHistory, , loaded] = persisted({ ...Persist.global("tips.queue"), sync: true }, History, { + current: "", + shownAt: 0, + dismissed: [], + dismissedAt: 0, + }) + const [tick, setTick] = createSignal(0) + const tip = createMemo(() => { + if (!loaded() || !ready()) return + tick() + const legacy = providerDismissal() + return selectTip( + tips, + legacy + ? { + ...history, + dismissed: [...history.dismissed, "provider"], + dismissedAt: Math.max(history.dismissedAt, legacy), + } + : history, + Date.now(), + ) + }) + + createEffect(() => { + const current = tip() + if (current && history.current !== current.id) setHistory({ current: current.id, shownAt: Date.now() }) + }) + + createEffect(() => { + if (!loaded() || !ready()) return + tick() + const now = Date.now() + const next = Math.min( + ...[ + Math.max(history.shownAt, history.dismissedAt, providerDismissal()) + TIP_INTERVAL_MS, + ...tips.flatMap((tip) => [tip.start ?? 0, tip.end ?? 0]), + ].filter((time) => time > now), + ) + if (!Number.isFinite(next)) return + const timer = setTimeout(() => setTick((value) => value + 1), Math.min(next - now, 2 ** 31 - 1)) + onCleanup(() => clearTimeout(timer)) + }) + + return { + tip, + dismiss() { + const current = tip() + if (current) setHistory({ current: "", dismissed: [...history.dismissed, current.id], dismissedAt: Date.now() }) + }, + } +} diff --git a/packages/app/src/tips/queue.test.ts b/packages/app/src/tips/queue.test.ts new file mode 100644 index 000000000000..ce016d834aa8 --- /dev/null +++ b/packages/app/src/tips/queue.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test" +import { selectTip, type Tip } from "./queue" + +const fresh = { current: "", shownAt: 0, dismissed: [], dismissedAt: 0 } +const first: Tip = { id: "first", text: "home.providerTip" } +const second: Tip = { id: "second", text: "home.sessionSearchTip" } +const tips = [first, second] +const hours = 60 * 60 * 1000 + +test("first eligible tip wins until it is handled or stops being eligible", () => { + expect(selectTip(tips, fresh, 1)).toBe(first) + expect(selectTip([{ ...first, when: () => false }, second], fresh, 1)).toBe(second) + expect(selectTip(tips, { ...fresh, current: second.id, shownAt: 1 }, 24 * hours)).toBe(second) +}) + +test("handling starts a full three-hour wait and permanently skips that id", () => { + const handled = { ...fresh, dismissedAt: hours, dismissed: [first.id] } + expect(selectTip(tips, handled, 4 * hours - 1)).toBeUndefined() + expect(selectTip(tips, handled, 4 * hours)).toBe(second) + expect(selectTip([first], handled, 365 * 24 * hours)).toBeUndefined() +}) + +test("promo starts inclusively, expires even while shown, and cannot immediately yield to another tip", () => { + const promo = { ...first, start: hours, end: hours + 1 } + expect(selectTip([promo], fresh, hours - 1)).toBeUndefined() + expect(selectTip([promo], fresh, hours)).toBe(promo) + const shown = { ...fresh, current: promo.id, shownAt: hours } + expect(selectTip([promo, second], shown, hours + 1)).toBeUndefined() + expect(selectTip([promo, second], shown, 4 * hours)).toBe(second) +}) diff --git a/packages/app/src/tips/queue.ts b/packages/app/src/tips/queue.ts new file mode 100644 index 000000000000..66b76848b662 --- /dev/null +++ b/packages/app/src/tips/queue.ts @@ -0,0 +1,35 @@ +import type { useLanguage } from "@/runtime/i18n/language" + +export const TIP_INTERVAL_MS = 3 * 60 * 60 * 1000 + +export type Tip = { + id: string + text: Parameters["t"]>[0] + action?: () => void + when?: () => boolean + start?: number + end?: number +} + +export type TipHistory = { + current: string + shownAt: number + dismissed: readonly string[] + dismissedAt: number +} + +/** Array order is priority. Dates are epoch milliseconds; the end is exclusive. */ +export function selectTip(tips: readonly Tip[], history: TipHistory, now: number) { + const eligible = (tip: Tip) => + !history.dismissed.includes(tip.id) && + (tip.start === undefined || tip.start <= now) && + (tip.end === undefined || now < tip.end) && + (tip.when?.() ?? true) + + const current = tips.find((tip) => tip.id === history.current && eligible(tip)) + if (current) return current + + const last = Math.max(history.shownAt, history.dismissedAt) + if (last && now < last + TIP_INTERVAL_MS) return + return tips.find(eligible) +} diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index cac31d2b577c..e74bcaef15c2 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -393,6 +393,7 @@ export type WorktreeInfo = { directory: AbsolutePath } export type LocationPublicInfo = { directory: AbsolutePath project: { id: ProjectID; directory: AbsolutePath; canonical: AbsolutePath } + experimental?: { tips_queue: boolean } } export type ModelRef = { id: string; providerID: ProviderID; variant?: string } @@ -2584,6 +2585,7 @@ export type ConfigEntry = portable_shell_scanner?: boolean codemode_vm?: boolean all_flags?: boolean + tips_queue?: boolean todo?: boolean background_compaction?: boolean media_blob?: boolean diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index ea2002a49b60..6848f57e5a7b 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -177,7 +177,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Location.PublicInfo" + "$ref": "#/components/schemas/Location.PublicInfoEncoded" } } } @@ -16656,6 +16656,9 @@ "all_flags": { "type": "boolean" }, + "tips_queue": { + "type": "boolean" + }, "todo": { "type": "boolean" }, @@ -20096,7 +20099,7 @@ "required": ["started"], "additionalProperties": false }, - "Location.PublicInfo": { + "Location.PublicInfoEncoded": { "type": "object", "properties": { "directory": { @@ -20117,6 +20120,16 @@ }, "required": ["id", "directory", "canonical"], "additionalProperties": false + }, + "experimental": { + "type": "object", + "properties": { + "tips_queue": { + "type": "boolean" + } + }, + "required": ["tips_queue"], + "additionalProperties": false } }, "required": ["directory", "project"], diff --git a/packages/schema/src/config/flag.ts b/packages/schema/src/config/flag.ts index f82cb806850c..221a4461fb72 100644 --- a/packages/schema/src/config/flag.ts +++ b/packages/schema/src/config/flag.ts @@ -9,6 +9,7 @@ export const wildcard = "all" * `experimental.all_flags: true` turn every flag on. Read one with Core's `Flag.enabled("")`. */ export const registry = { + tips_queue: "Show paced, one-time tips on the new session screen.", todo: "Enable the experimental todowrite tool.", background_compaction: "Enable experimental background compaction: summarize the conversation in the background as it nears the compaction threshold, so automatic compaction does not wait for the summary. Background summaries that are never used still cost tokens.", diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts index 205329e5fcaf..d5c44d6b5648 100644 --- a/packages/schema/src/location.ts +++ b/packages/schema/src/location.ts @@ -26,10 +26,10 @@ export class Info extends Schema.Class("Location.Info")({ }), }) {} -export const PublicInfo = Schema.Struct(Struct.omit(Info.fields, ["workspaceID"])).annotate({ - identifier: "Location.PublicInfo", -}) -export interface PublicInfo extends Schema.Schema.Type {} +export class PublicInfo extends Schema.Class("Location.PublicInfo")({ + ...Struct.omit(Info.fields, ["workspaceID"]), + experimental: Schema.optionalKey(Schema.Struct({ tips_queue: Schema.Boolean })), +}) {} export function response(data: S) { return Schema.Struct({ location: PublicRef, data }) diff --git a/packages/server/src/handlers/location.ts b/packages/server/src/handlers/location.ts index 942b9d923bcf..8a39e66dcc4a 100644 --- a/packages/server/src/handlers/location.ts +++ b/packages/server/src/handlers/location.ts @@ -1,4 +1,6 @@ +import { Flag } from "@opencode/core/flag" import { Location } from "@opencode/core/location" +import { PublicInfo } from "@opencode/schema/location" import { LocationServiceMap } from "@opencode/core/location-service-map" import { ServiceUnavailableError } from "@opencode/protocol/errors" import { Cause, Effect } from "effect" @@ -13,9 +15,10 @@ export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (han "location.get", Effect.fn(function* () { const location = yield* Location.Service - return new Location.Info({ + return new PublicInfo({ directory: location.directory, project: location.project, + experimental: { tips_queue: yield* Flag.enabled("tips_queue") }, }) }), ) From c8409fab9eaa5ceeed2ad8516364502e3df0d507 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 12 Nov 2026 23:07:09 -0500 Subject: [PATCH 2/5] feat(app): show paced one-time tip toasts --- packages/app/src/new-session/view.tsx | 69 +++------ packages/app/src/runtime/i18n/en.ts | 6 +- .../src/shell/notifications/coordinator.ts | 40 ++++- packages/app/src/shell/shell.tsx | 3 + packages/app/src/tips/model.ts | 145 +++++++++++++----- packages/app/src/tips/queue.test.ts | 16 +- packages/app/src/tips/queue.ts | 12 +- packages/schema/src/config/flag.ts | 2 +- packages/ui/src/feedback/toast/toast.tsx | 8 +- 9 files changed, 201 insertions(+), 100 deletions(-) diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index 763a650d98e6..107df2cb2403 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -31,8 +31,6 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout" import { Persist, persisted } from "@/runtime/persistence/storage" import { Persistence } from "@/runtime/persistence/schema" import { useSettings } from "@/settings/model" -import { createTipQueue } from "@/tips/model" -import type { Tip } from "@/tips/queue" import type { NewSessionWorkspaceController } from "./workspace/controller" import { CHECKOUT_PLACEHOLDER_DELAY_MS, @@ -184,7 +182,6 @@ export function NewSessionView(props: { function NewSessionTips(props: { selection: ComposerModel["model"]["selection"]; onDone: () => void }) { const language = useLanguage() - const command = useCommand() const dialog = useDialog() const sdk = useWorkspaceLocation() const providers = useProviders(() => sdk().directory) @@ -202,46 +199,27 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"]; Date.now() - providerState.dismissedAt >= providerTipDismissalDuration, ) - const provider: Tip = { - id: "provider", - text: "home.providerTip", - when: () => providers.anyConnection() === false, - action: async () => { - const { DialogConnectProvider } = await import("@/providers/connect/dialog") - void dialog.show(() => ( - - )) - }, - } - const enabled = () => sdk().current?.experimental?.tips_queue === true - const queue = createTipQueue( - [ - provider, - { id: "session-search", text: "home.sessionSearchTip", action: () => command.trigger("sessions.search") }, - ], - () => enabled() && providerReady() && providers.anyConnection() !== undefined, - () => providerState.dismissedAt, - ) - const tip = () => (enabled() ? queue.tip() : sdk().current && providerVisible() ? provider : undefined) - // Keep the departing tip's contents for its exit animation. - const displayed = createMemo((previous) => tip() ?? previous, provider) + const tip = providerVisible const [ref, setRef] = createSignal() const presence = createPresence({ - show: () => !!tip(), + show: tip, element: () => ref() ?? null, }) const open = () => { - const action = tip()?.action - if (!action) return - if (enabled()) queue.dismiss() - action() + if (!tip()) return + + void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => { + void dialog.show(() => ( + + )) + }) } const dismiss = () => { if (!tip()) return - if (enabled()) return queue.dismiss() + setProviderState("dismissedAt", Date.now()) } @@ -251,27 +229,20 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"];
- {language.t(displayed().text)} - } + - + {language.t("home.providerTip")} + + () => void) { + const lifetime = new AbortController() + + const closed = new Promise((resolve) => + lifetime.signal.addEventListener("abort", () => resolve(), { once: true }), + ) + + let hide: VoidFunction | undefined + + const open = () => { + if (hide || lifetime.signal.aborted || !document.hasFocus() || document.hidden) return + hide = untrack(show) + + return closed + } + + const focus = () => { + if (!locks) return void open() + void locks + .request("opencode:tip", { ifAvailable: true }, (lock) => (lock ? open() : undefined)) + .catch(() => undefined) + } + + // Plain HTTP has no Web Locks. Only its focused window keeps a toast mounted. + const blur = () => { + if (locks) return + hide?.() + hide = undefined + } + + window.addEventListener("focus", focus, { signal: lifetime.signal }) + window.addEventListener("blur", blur, { signal: lifetime.signal }) + onCleanup(() => { + lifetime.abort() + hide?.() + }) + focus() + }, sound(eventID: string, run: () => Promise | void) { return once("sound", eventID, run) }, diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index 9aaad59e5f76..e421c3a96f95 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -7,6 +7,7 @@ import { prefersReducedMotion } from "@opencode/ui/motion" import { Titlebar } from "@/shell/titlebar/titlebar" import { usePlatform } from "@/runtime/platform/platform" import { ToastRegion } from "@/shell/notifications/toast" +import { createTipToasts } from "@/tips/model" import { UploadToastHost } from "@/composer/attachments/uploads" import { useSettingsSurface } from "@/settings/surface" import { useSettings } from "@/settings/model" @@ -43,6 +44,8 @@ export default function Layout(props: ParentProps) { const tabs = useTabs() const mobile = createMediaQuery("(max-width: 767px)") + createTipToasts() + const [state, setState] = createStore<{ tabsWidth: number tabsMount: HTMLElement | undefined diff --git a/packages/app/src/tips/model.ts b/packages/app/src/tips/model.ts index dbc7aeb806e9..6f5462ca4910 100644 --- a/packages/app/src/tips/model.ts +++ b/packages/app/src/tips/model.ts @@ -1,9 +1,17 @@ +import { showToast, toaster } from "@opencode/ui/toast" import { Schema } from "effect" import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { useLanguage } from "@/runtime/i18n/language" import { Persistence } from "@/runtime/persistence/schema" import { Persist, persisted } from "@/runtime/persistence/storage" +import { usePlatform } from "@/runtime/platform/platform" +import { ServerConnection, useServers } from "@/runtime/server/registry" +import { useGlobal } from "@/runtime/server/runtime" +import { useCommand } from "@/shell/commands/command" import { selectTip, TIP_INTERVAL_MS, type Tip } from "./queue" +const STARTUP_DELAY = 2 * 60 * 1000 + const History = Persistence.struct({ current: Schema.String, shownAt: Schema.Finite, @@ -11,56 +19,117 @@ const History = Persistence.struct({ dismissedAt: Schema.Finite, }) -export function createTipQueue(tips: readonly Tip[], ready: () => boolean, providerDismissal: () => number) { +export function createTipToasts() { + const global = useGlobal() + const servers = useServers() + const language = useLanguage() + const platform = usePlatform() + const command = useCommand() + + const server = createMemo(() => { + const conn = servers.visible.find(servers.current) ?? servers.visible[0] + + return conn && global.serverCtx(ServerConnection.key(conn)) + }) + + const location = () => server()?.data.location + const [awake, setAwake] = createSignal(false) + const startup = setTimeout(() => setAwake(true), STARTUP_DELAY) + onCleanup(() => clearTimeout(startup)) + createEffect(() => { + if (awake() && server()?.sdk.connection.status() === "connected") + void location() + ?.syncInfo() + .catch(() => undefined) + }) + + const tips: Tip[] = [ + { + id: "browse", + title: "tips.browse.title", + description: "tips.browse.description", + when: () => + !location() + ?.mcp.server.list() + ?.some((item) => item.name === "browse"), + action: { + label: "tips.browse.install", + run: () => platform.openExternal("https://chromewebstore.google.com/detail/mfnicocicmmlkpjnaffgihfjhdgjkdjg"), + }, + }, + { + id: "session-search", + title: "tips.search.title", + description: "tips.search.description", + action: { label: "command.session.search", run: () => command.trigger("sessions.search") }, + }, + ] + const [history, setHistory, , loaded] = persisted({ ...Persist.global("tips.queue"), sync: true }, History, { current: "", shownAt: 0, dismissed: [], dismissedAt: 0, }) - const [tick, setTick] = createSignal(0) - const tip = createMemo(() => { - if (!loaded() || !ready()) return - tick() - const legacy = providerDismissal() - return selectTip( - tips, - legacy - ? { - ...history, - dismissed: [...history.dismissed, "provider"], - dismissedAt: Math.max(history.dismissedAt, legacy), - } - : history, - Date.now(), - ) - }) + const [tick, setTick] = createSignal(0) + const enabled = () => awake() && location()?.info()?.experimental?.tips_queue === true createEffect(() => { - const current = tip() - if (current && history.current !== current.id) setHistory({ current: current.id, shownAt: Date.now() }) + if (enabled()) + void location() + ?.mcp.server.sync() + .catch(() => undefined) }) - createEffect(() => { - if (!loaded() || !ready()) return + const selected = createMemo(() => { + if (!enabled() || !loaded() || !location()?.mcp.server.list()) return tick() const now = Date.now() - const next = Math.min( - ...[ - Math.max(history.shownAt, history.dismissedAt, providerDismissal()) + TIP_INTERVAL_MS, - ...tips.flatMap((tip) => [tip.start ?? 0, tip.end ?? 0]), - ].filter((time) => time > now), - ) - if (!Number.isFinite(next)) return - const timer = setTimeout(() => setTick((value) => value + 1), Math.min(next - now, 2 ** 31 - 1)) - onCleanup(() => clearTimeout(timer)) + + const times = [ + Math.max(history.shownAt, history.dismissedAt) + TIP_INTERVAL_MS, + ...tips.flatMap((tip) => [tip.start ?? 0, tip.end ?? 0]), + ] + + const next = Math.min(...times.filter((time) => time > now)) + + if (Number.isFinite(next)) { + const timer = setTimeout(() => setTick((n) => n + 1), Math.min(next - now, 2 ** 31 - 1)) + onCleanup(() => clearTimeout(timer)) + } + + return selectTip(tips, history, now) }) - return { - tip, - dismiss() { - const current = tip() - if (current) setHistory({ current: "", dismissed: [...history.dismissed, current.id], dismissedAt: Date.now() }) - }, - } + createEffect(() => { + const tip = selected() + + if (!tip) return + + const handled = () => + setHistory({ current: "", dismissed: [...history.dismissed, tip.id], dismissedAt: Date.now() }) + + global.notifications.tip(() => { + if (history.current !== tip.id) setHistory({ current: tip.id, shownAt: Date.now() }) + const action = tip.action + + const id = showToast({ + persistent: true, + title: language.t(tip.title), + description: language.t(tip.description), + onDismiss: handled, + actions: action && [ + { + label: language.t(action.label), + onClick: () => { + handled() + action.run() + }, + }, + ], + }) + + return () => toaster.dismiss(id) + }) + }) } diff --git a/packages/app/src/tips/queue.test.ts b/packages/app/src/tips/queue.test.ts index ce016d834aa8..e4ecf1e52785 100644 --- a/packages/app/src/tips/queue.test.ts +++ b/packages/app/src/tips/queue.test.ts @@ -2,9 +2,13 @@ import { expect, test } from "bun:test" import { selectTip, type Tip } from "./queue" const fresh = { current: "", shownAt: 0, dismissed: [], dismissedAt: 0 } -const first: Tip = { id: "first", text: "home.providerTip" } -const second: Tip = { id: "second", text: "home.sessionSearchTip" } + +const first: Tip = { id: "first", title: "tips.browse.title", description: "tips.browse.description" } + +const second: Tip = { ...first, id: "second" } + const tips = [first, second] + const hours = 60 * 60 * 1000 test("first eligible tip wins until it is handled or stops being eligible", () => { @@ -13,10 +17,10 @@ test("first eligible tip wins until it is handled or stops being eligible", () = expect(selectTip(tips, { ...fresh, current: second.id, shownAt: 1 }, 24 * hours)).toBe(second) }) -test("handling starts a full three-hour wait and permanently skips that id", () => { +test("handling starts a full day's wait and permanently skips that id", () => { const handled = { ...fresh, dismissedAt: hours, dismissed: [first.id] } - expect(selectTip(tips, handled, 4 * hours - 1)).toBeUndefined() - expect(selectTip(tips, handled, 4 * hours)).toBe(second) + expect(selectTip(tips, handled, 25 * hours - 1)).toBeUndefined() + expect(selectTip(tips, handled, 25 * hours)).toBe(second) expect(selectTip([first], handled, 365 * 24 * hours)).toBeUndefined() }) @@ -26,5 +30,5 @@ test("promo starts inclusively, expires even while shown, and cannot immediately expect(selectTip([promo], fresh, hours)).toBe(promo) const shown = { ...fresh, current: promo.id, shownAt: hours } expect(selectTip([promo, second], shown, hours + 1)).toBeUndefined() - expect(selectTip([promo, second], shown, 4 * hours)).toBe(second) + expect(selectTip([promo, second], shown, 25 * hours)).toBe(second) }) diff --git a/packages/app/src/tips/queue.ts b/packages/app/src/tips/queue.ts index 66b76848b662..85b781f0c550 100644 --- a/packages/app/src/tips/queue.ts +++ b/packages/app/src/tips/queue.ts @@ -1,11 +1,14 @@ import type { useLanguage } from "@/runtime/i18n/language" -export const TIP_INTERVAL_MS = 3 * 60 * 60 * 1000 +export const TIP_INTERVAL_MS = 24 * 60 * 60 * 1000 + +type Text = Parameters["t"]>[0] export type Tip = { id: string - text: Parameters["t"]>[0] - action?: () => void + title: Text + description: Text + action?: { label: Text; run: () => void } when?: () => boolean start?: number end?: number @@ -27,9 +30,12 @@ export function selectTip(tips: readonly Tip[], history: TipHistory, now: number (tip.when?.() ?? true) const current = tips.find((tip) => tip.id === history.current && eligible(tip)) + if (current) return current const last = Math.max(history.shownAt, history.dismissedAt) + if (last && now < last + TIP_INTERVAL_MS) return + return tips.find(eligible) } diff --git a/packages/schema/src/config/flag.ts b/packages/schema/src/config/flag.ts index 221a4461fb72..996579e51cfc 100644 --- a/packages/schema/src/config/flag.ts +++ b/packages/schema/src/config/flag.ts @@ -9,7 +9,7 @@ export const wildcard = "all" * `experimental.all_flags: true` turn every flag on. Read one with Core's `Flag.enabled("")`. */ export const registry = { - tips_queue: "Show paced, one-time tips on the new session screen.", + tips_queue: "Show paced, one-time tip notifications in the app.", todo: "Enable the experimental todowrite tool.", background_compaction: "Enable experimental background compaction: summarize the conversation in the background as it nears the compaction threshold, so automatic compaction does not wait for the summary. Background summaries that are never used still cost tokens.", diff --git a/packages/ui/src/feedback/toast/toast.tsx b/packages/ui/src/feedback/toast/toast.tsx index 0136ab3faff9..1731107fe2fe 100644 --- a/packages/ui/src/feedback/toast/toast.tsx +++ b/packages/ui/src/feedback/toast/toast.tsx @@ -183,6 +183,8 @@ export interface ToastAction { } export interface ToastOptions { + /** Called when the user closes or swipes away the toast. */ + onDismiss?: () => void title?: string description?: string /** An element, or a render function the toast calls under its own owner, for icons created from imperative code. */ @@ -254,7 +256,11 @@ function publishToast(id: number, options: ToastOptions) { className: `toast-v2 toast-v2--${options.variant ?? "default"}`, testId: `toast-v2-${id}`, unstyled: true, - onDismiss: release, + onDismiss: () => { + const current = selectToastOptions(registry, id) + release() + current?.onDismiss?.() + }, onAutoClose: release, }) } From 2daab9970212056294ccf8adbcd87d278d5f4398 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 12 Nov 2026 23:24:02 -0500 Subject: [PATCH 3/5] feat(app): enable tip toasts in all builds --- packages/app/src/tips/model.ts | 21 ++++++++----------- .../client/src/promise/generated/types.ts | 2 -- packages/protocol/openapi.json | 17 ++------------- packages/schema/src/config/flag.ts | 1 - packages/schema/src/location.ts | 8 +++---- packages/server/src/handlers/location.ts | 5 +---- 6 files changed, 16 insertions(+), 38 deletions(-) diff --git a/packages/app/src/tips/model.ts b/packages/app/src/tips/model.ts index 6f5462ca4910..cff17993c694 100644 --- a/packages/app/src/tips/model.ts +++ b/packages/app/src/tips/model.ts @@ -37,10 +37,14 @@ export function createTipToasts() { const startup = setTimeout(() => setAwake(true), STARTUP_DELAY) onCleanup(() => clearTimeout(startup)) createEffect(() => { - if (awake() && server()?.sdk.connection.status() === "connected") - void location() - ?.syncInfo() - .catch(() => undefined) + const ctx = server() + + if (!awake() || ctx?.sdk.connection.status() !== "connected") return + const location = ctx.data.location + void location + .syncInfo() + .then(() => location.mcp.server.sync()) + .catch(() => undefined) }) const tips: Tip[] = [ @@ -73,16 +77,9 @@ export function createTipToasts() { }) const [tick, setTick] = createSignal(0) - const enabled = () => awake() && location()?.info()?.experimental?.tips_queue === true - createEffect(() => { - if (enabled()) - void location() - ?.mcp.server.sync() - .catch(() => undefined) - }) const selected = createMemo(() => { - if (!enabled() || !loaded() || !location()?.mcp.server.list()) return + if (!awake() || !loaded() || !location()?.mcp.server.list()) return tick() const now = Date.now() diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index e74bcaef15c2..cac31d2b577c 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -393,7 +393,6 @@ export type WorktreeInfo = { directory: AbsolutePath } export type LocationPublicInfo = { directory: AbsolutePath project: { id: ProjectID; directory: AbsolutePath; canonical: AbsolutePath } - experimental?: { tips_queue: boolean } } export type ModelRef = { id: string; providerID: ProviderID; variant?: string } @@ -2585,7 +2584,6 @@ export type ConfigEntry = portable_shell_scanner?: boolean codemode_vm?: boolean all_flags?: boolean - tips_queue?: boolean todo?: boolean background_compaction?: boolean media_blob?: boolean diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 6848f57e5a7b..ea2002a49b60 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -177,7 +177,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Location.PublicInfoEncoded" + "$ref": "#/components/schemas/Location.PublicInfo" } } } @@ -16656,9 +16656,6 @@ "all_flags": { "type": "boolean" }, - "tips_queue": { - "type": "boolean" - }, "todo": { "type": "boolean" }, @@ -20099,7 +20096,7 @@ "required": ["started"], "additionalProperties": false }, - "Location.PublicInfoEncoded": { + "Location.PublicInfo": { "type": "object", "properties": { "directory": { @@ -20120,16 +20117,6 @@ }, "required": ["id", "directory", "canonical"], "additionalProperties": false - }, - "experimental": { - "type": "object", - "properties": { - "tips_queue": { - "type": "boolean" - } - }, - "required": ["tips_queue"], - "additionalProperties": false } }, "required": ["directory", "project"], diff --git a/packages/schema/src/config/flag.ts b/packages/schema/src/config/flag.ts index 996579e51cfc..f82cb806850c 100644 --- a/packages/schema/src/config/flag.ts +++ b/packages/schema/src/config/flag.ts @@ -9,7 +9,6 @@ export const wildcard = "all" * `experimental.all_flags: true` turn every flag on. Read one with Core's `Flag.enabled("")`. */ export const registry = { - tips_queue: "Show paced, one-time tip notifications in the app.", todo: "Enable the experimental todowrite tool.", background_compaction: "Enable experimental background compaction: summarize the conversation in the background as it nears the compaction threshold, so automatic compaction does not wait for the summary. Background summaries that are never used still cost tokens.", diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts index d5c44d6b5648..205329e5fcaf 100644 --- a/packages/schema/src/location.ts +++ b/packages/schema/src/location.ts @@ -26,10 +26,10 @@ export class Info extends Schema.Class("Location.Info")({ }), }) {} -export class PublicInfo extends Schema.Class("Location.PublicInfo")({ - ...Struct.omit(Info.fields, ["workspaceID"]), - experimental: Schema.optionalKey(Schema.Struct({ tips_queue: Schema.Boolean })), -}) {} +export const PublicInfo = Schema.Struct(Struct.omit(Info.fields, ["workspaceID"])).annotate({ + identifier: "Location.PublicInfo", +}) +export interface PublicInfo extends Schema.Schema.Type {} export function response(data: S) { return Schema.Struct({ location: PublicRef, data }) diff --git a/packages/server/src/handlers/location.ts b/packages/server/src/handlers/location.ts index 8a39e66dcc4a..942b9d923bcf 100644 --- a/packages/server/src/handlers/location.ts +++ b/packages/server/src/handlers/location.ts @@ -1,6 +1,4 @@ -import { Flag } from "@opencode/core/flag" import { Location } from "@opencode/core/location" -import { PublicInfo } from "@opencode/schema/location" import { LocationServiceMap } from "@opencode/core/location-service-map" import { ServiceUnavailableError } from "@opencode/protocol/errors" import { Cause, Effect } from "effect" @@ -15,10 +13,9 @@ export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (han "location.get", Effect.fn(function* () { const location = yield* Location.Service - return new PublicInfo({ + return new Location.Info({ directory: location.directory, project: location.project, - experimental: { tips_queue: yield* Flag.enabled("tips_queue") }, }) }), ) From 49f2243543e70ef9ce99cb3afa3a0bd1d44f53b5 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 12 Nov 2026 23:25:13 -0500 Subject: [PATCH 4/5] fix(app): update browser control tip copy --- packages/app/src/runtime/i18n/en.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 34eb5e72666a..8910010f870a 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -613,8 +613,8 @@ export const dict = { "home.sessions.group.yesterday": "Yesterday", "home.sessions.group.older": "Older", "home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc", - "tips.browse.title": "Drive your real browser", - "tips.browse.description": "Use browse to let OpenCode work in Chrome with the sites you’re already signed in to.", + "tips.browse.title": "Browser control", + "tips.browse.description": "Let OpenCode work in your browser with the sites you’re already signed in to.", "tips.browse.install": "Install", "tips.search.title": "Find a past conversation", "tips.search.description": "Search your sessions to pick up where you left off.", From a09820781c19ed419be689c51337085c115bf8ba Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 12 Nov 2026 23:25:29 -0500 Subject: [PATCH 5/5] fix(app): retitle browser tip --- packages/app/src/runtime/i18n/en.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 8910010f870a..0b23f24c9196 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -613,7 +613,7 @@ export const dict = { "home.sessions.group.yesterday": "Yesterday", "home.sessions.group.older": "Older", "home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc", - "tips.browse.title": "Browser control", + "tips.browse.title": "Control your browser", "tips.browse.description": "Let OpenCode work in your browser with the sites you’re already signed in to.", "tips.browse.install": "Install", "tips.search.title": "Find a past conversation",