Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 39 additions & 1 deletion packages/app/src/shell/notifications/coordinator.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -75,6 +75,44 @@ export function createNotificationCoordinator() {
}

return {
tip(show: () => () => void) {
const lifetime = new AbortController()

const closed = new Promise<void>((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<T>(eventID: string, run: () => Promise<T> | void) {
return once("sound", eventID, run)
},
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/shell/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
132 changes: 132 additions & 0 deletions packages/app/src/tips/model.ts
Original file line number Diff line number Diff line change
@@ -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"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description says the first tip links to https://opencode.ai/product/browse, but Install opens the Chrome Web Store listing directly. Is that intended? Also, the tip's when checks for an MCP server named browse. If installing the extension alone doesn't register that server, the tip will still be eligible after a user has installed browse. In practice it won't come back, because clicking Install already marks it as handled.

},
},
{
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)
})
})
}
34 changes: 34 additions & 0 deletions packages/app/src/tips/queue.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
41 changes: 41 additions & 0 deletions packages/app/src/tips/queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { useLanguage } from "@/runtime/i18n/language"

export const TIP_INTERVAL_MS = 24 * 60 * 60 * 1000

type Text = Parameters<ReturnType<typeof useLanguage>["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)
}
8 changes: 7 additions & 1 deletion packages/ui/src/feedback/toast/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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,
})
}
Expand Down
Loading