diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 1874157978a7..0b23f24c9196 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -613,6 +613,11 @@ 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": "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", + "tips.search.description": "Search your sessions to pick up where you left off.", "home.workspaceTip": "Start next session in a new workspace to keep changes isolated", "session.tab.session": "Session", diff --git a/packages/app/src/shell/notifications/coordinator.ts b/packages/app/src/shell/notifications/coordinator.ts index 66d5494f20f0..a0960d56e548 100644 --- a/packages/app/src/shell/notifications/coordinator.ts +++ b/packages/app/src/shell/notifications/coordinator.ts @@ -1,4 +1,4 @@ -import { onCleanup } from "solid-js" +import { onCleanup, untrack } from "solid-js" import { Predicate } from "effect" import { INITIAL_FOCUS_LOCK_STATE, transitionFocusLock, type FocusLockEvent } from "./focus-lock-machine" @@ -75,6 +75,44 @@ export function createNotificationCoordinator() { } return { + tip(show: () => () => 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 new file mode 100644 index 000000000000..cff17993c694 --- /dev/null +++ b/packages/app/src/tips/model.ts @@ -0,0 +1,132 @@ +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, + dismissed: Persistence.array(Schema.String), + dismissedAt: Schema.Finite, +}) + +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(() => { + 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[] = [ + { + 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 selected = createMemo(() => { + if (!awake() || !loaded() || !location()?.mcp.server.list()) return + tick() + const now = Date.now() + + 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) + }) + + 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 new file mode 100644 index 000000000000..e4ecf1e52785 --- /dev/null +++ b/packages/app/src/tips/queue.test.ts @@ -0,0 +1,34 @@ +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", 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", () => { + 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 day's wait and permanently skips that id", () => { + const handled = { ...fresh, dismissedAt: hours, dismissed: [first.id] } + expect(selectTip(tips, handled, 25 * hours - 1)).toBeUndefined() + expect(selectTip(tips, handled, 25 * 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, 25 * 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..85b781f0c550 --- /dev/null +++ b/packages/app/src/tips/queue.ts @@ -0,0 +1,41 @@ +import type { useLanguage } from "@/runtime/i18n/language" + +export const TIP_INTERVAL_MS = 24 * 60 * 60 * 1000 + +type Text = Parameters["t"]>[0] + +export type Tip = { + id: string + title: Text + description: Text + action?: { label: Text; run: () => 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/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, }) }