Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/mobile-app-promo-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"kilo-code": minor
"@kilocode/cli": minor
---

Show a dismissible notice promoting the Kilo mobile app to users who have previously used Cloud Agents (the `/remote` command in the CLI). The notice links to the mobile app announcement and stays hidden for everyone else, and once dismissed it never shows again.
34 changes: 32 additions & 2 deletions packages/kilo-vscode/src/kilo-provider/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,29 @@ export interface NotificationsContext {
notify: (id: string) => void
}

const MOBILE_APP_NOTICE_ID = "mobile-app-promo"
const MOBILE_APP_NOTICE_URL = "https://blog.kilo.ai/p/kilo-app-for-ios-and-android-is-live"

/**
* Purely local (non-server-fetched) notice promoting the Kilo mobile app. Gated by the
* local `kilo serve` instance's `kilocode.mobileAppNotice()` endpoint, which is only true
* for users who have previously enabled a Cloud Agent / remote session relay from the CLI
* (see `Notices.markCloudAgentUsed()` in `packages/opencode/src/kilocode/notices.ts`).
* Dismissal reuses the existing `kilo.dismissedNotificationIds` globalState flow below.
*/
async function localNotifications(client: KiloClient): Promise<NotificationItem[]> {
const res = await client.kilocode.mobileAppNotice().catch(() => null)
if (!res?.data?.show) return []
return [
{
id: MOBILE_APP_NOTICE_ID,
title: "Kilo Mobile App",
message: "Continue your /remote sessions in the Kilo mobile app.",
action: { actionText: "Open", actionURL: MOBILE_APP_NOTICE_URL },
},
]
}

export async function fetchAndSendNotifications(ctx: NotificationsContext): Promise<void> {
if (!ctx.client) {
const cached = ctx.cached()
Expand All @@ -48,8 +71,11 @@ export async function fetchAndSendNotifications(ctx: NotificationsContext): Prom
}

try {
const { data: all } = await retry(() => ctx.client!.kilo.notifications(undefined, { throwOnError: true }))
const notifications = all.filter((n) => !n.showIn || n.showIn.includes("extension"))
const [{ data: all }, local] = await Promise.all([
retry(() => ctx.client!.kilo.notifications(undefined, { throwOnError: true })),
localNotifications(ctx.client),
])
const notifications = [...local, ...all.filter((n) => !n.showIn || n.showIn.includes("extension"))]
const existing = ctx.context?.globalState.get<string[]>(KEY, []) ?? []
const active = new Set(notifications.map((n) => n.id))
const dismissedIds = notifications.length > 0 ? existing.filter((id) => active.has(id)) : existing
Expand All @@ -67,6 +93,10 @@ export async function dismissNotification(ctx: NotificationsContext, id: string)
const existing = ctx.context.globalState.get<string[]>(KEY, [])
if (!existing.includes(id)) await ctx.context.globalState.update(KEY, [...existing, id])

// Also persist dismissal on the local kilo serve instance so the CLI/TUI (which shares
// the same machine-global notice flag) stops showing it too.
if (id === MOBILE_APP_NOTICE_ID) await ctx.client?.kilocode.dismissMobileAppNotice().catch(() => undefined)

const cached = ctx.cached()
if (cached && !cached.dismissedIds.includes(id)) {
ctx.set({
Expand Down
90 changes: 90 additions & 0 deletions packages/kilo-vscode/tests/unit/mobile-app-notice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from "bun:test"
import {
dismissNotification,
fetchAndSendNotifications,
type NotificationsContext,
} from "../../src/kilo-provider/notifications"

function makeContext(input: { show: boolean; initialDismissed?: string[] }) {
const flag = new Map<string, unknown>(
input.initialDismissed ? [["kilo.dismissedNotificationIds", input.initialDismissed]] : [],
)
let dismissMobileAppNoticeCalls = 0
let cached: NotificationsContext["cached"] extends () => infer R ? R : never = null
const posted: unknown[] = []

const client = {
kilo: {
notifications: async () => ({ data: [] }),
},
kilocode: {
mobileAppNotice: async () => ({ data: { show: input.show } }),
dismissMobileAppNotice: async () => {
dismissMobileAppNoticeCalls++
return { data: true }
},
},
}

const ctx: NotificationsContext = {
context: {
globalState: {
get: <T>(key: string, fallback?: T) => (flag.has(key) ? (flag.get(key) as T) : fallback),
update: async (key: string, value: unknown) => {
flag.set(key, value)
},
},
} as any,
client: client as any,
cached: () => cached,
set: (message) => {
cached = message
},
post: (message) => {
posted.push(message)
},
notify: () => {},
}

return { ctx, flag, posted, dismissMobileAppNoticeCallCount: () => dismissMobileAppNoticeCalls }
}

describe("mobile app promo notice", () => {
it("is included when the local kilo serve reports Cloud Agent usage", async () => {
const { ctx, posted } = makeContext({ show: true })

await fetchAndSendNotifications(ctx)

const message = posted[0] as { notifications: { id: string }[] }
expect(message.notifications.some((n) => n.id === "mobile-app-promo")).toBe(true)
})

it("is omitted for users who have never used a Cloud Agent / remote session", async () => {
const { ctx, posted } = makeContext({ show: false })

await fetchAndSendNotifications(ctx)

const message = posted[0] as { notifications: { id: string }[] }
expect(message.notifications.some((n) => n.id === "mobile-app-promo")).toBe(false)
})

it("persists dismissal in globalState and propagates it to the local kilo serve instance", async () => {
const { ctx, flag, dismissMobileAppNoticeCallCount } = makeContext({ show: true })

await fetchAndSendNotifications(ctx)
await dismissNotification(ctx, "mobile-app-promo")

expect(flag.get("kilo.dismissedNotificationIds")).toContain("mobile-app-promo")
expect(dismissMobileAppNoticeCallCount()).toBe(1)
})

it("keeps the notice dismissed across subsequent fetches even though the server still reports show=true", async () => {
const { ctx, posted } = makeContext({ show: true, initialDismissed: ["mobile-app-promo"] })

await fetchAndSendNotifications(ctx)

const message = posted[0] as { notifications: { id: string }[]; dismissedIds: string[] }
expect(message.notifications.some((n) => n.id === "mobile-app-promo")).toBe(true)
expect(message.dismissedIds).toContain("mobile-app-promo")
})
})
3 changes: 3 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
import { useMobileAppNotice } from "@/kilocode/cli/cmd/tui/routes/session/mobile-app-notice" // kilocode_change
import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff"
import { KILO_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
Expand Down Expand Up @@ -460,6 +461,8 @@ export function Session() {
return exit.message.set(banner(title, session()?.id, UI.Style.TEXT_DIM, UI.Style.TEXT_NORMAL))
})

useMobileAppNotice({ sdk, dialog })

const [exitPress, setExitPress] = createSignal(0)
useBindings(() => ({
enabled: Boolean(session()?.parentID),
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/kilo-sessions/kilo-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Vcs } from "@/project/vcs"
import simpleGit from "simple-git"
import { RemoteWS } from "@/kilo-sessions/remote-ws"
import { RemoteSender } from "@/kilo-sessions/remote-sender"
import { Notices } from "@/kilocode/notices" // kilocode_change
import { SessionStatus } from "@/session/status"
import { Telemetry } from "@kilocode/kilo-telemetry"
import { Question } from "@/question"
Expand Down Expand Up @@ -465,6 +466,9 @@ export namespace KiloSessions {
remote = { conn, sender }
log.info("remote connection enabled", { connected: conn.connected })
Telemetry.trackRemoteConnectionOpened()
// kilocode_change start - mark Cloud Agent (remote) usage so cross-surface promo notices can target these users
void Notices.markCloudAgentUsed().catch((err) => log.warn("failed to persist cloud agent usage", { error: String(err) }))
// kilocode_change end
void Bus.publish(Instance.current, Event.RemoteStatusChanged, { enabled: true, connected: conn.connected })
})()
.catch((err) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// kilocode_change - new file

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: Unnecessary kilocode_change marker

Per AGENTS.md, files/directories whose path contains kilocode never need kilocode_change markers — this file already lives under src/kilocode/.... The // kilocode_change - new file marker here is redundant and can be dropped.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

/**
* "Continue your /remote sessions in the Kilo mobile app" promo notice.
*
* Only shown to users who have previously enabled a Cloud Agent / remote session relay
* (`Notices.markCloudAgentUsed()` in `@/kilocode/notices`, set from `enableRemote()` in
* `src/kilo-sessions/kilo-sessions.ts`). Persists until the user explicitly dismisses it
* via the "don't show again" action.
*/
import { onMount } from "solid-js"
import type { useDialog } from "@tui/ui/dialog"
import { DialogRetryAction } from "@tui/component/dialog-retry-action"

export const MOBILE_APP_NOTICE_URL = "https://blog.kilo.ai/p/kilo-app-for-ios-and-android-is-live"

// Loosely typed to avoid coupling this module to the exact generated SDK client shape.
type SDKLike = {
client: {
kilocode: {
mobileAppNotice: (...args: any[]) => Promise<{ data?: { show: boolean } }>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: New any usage in SDKLike

(...args: any[]) introduces any, which the style guide asks to avoid. Since only zero-argument calls are ever made (mobileAppNotice() / dismissMobileAppNotice() are called with no args in this file and in notifications.ts), this could be typed as () => Promise<{ data?: { show: boolean } }> and () => Promise<unknown> without coupling to the full generated SDK client shape.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

dismissMobileAppNotice: (...args: any[]) => Promise<unknown>
}
}
}

export function useMobileAppNotice(deps: { sdk: SDKLike; dialog: ReturnType<typeof useDialog> }) {
onMount(() => {
void (async () => {
if (deps.dialog.stack.length > 0) return
const res = await deps.sdk.client.kilocode.mobileAppNotice().catch(() => null)
if (!res?.data?.show) return
if (deps.dialog.stack.length > 0) return

const dontShowAgain = await DialogRetryAction.show(deps.dialog, {
title: "Kilo Mobile App",
message: "Continue your /remote sessions in the Kilo mobile app.",
label: "Open",
link: MOBILE_APP_NOTICE_URL,
})
if (dontShowAgain) await deps.sdk.client.kilocode.dismissMobileAppNotice().catch(() => undefined)
})()
})
}
48 changes: 48 additions & 0 deletions packages/opencode/src/kilocode/notices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Persisted, machine-global "notice" flags shared by every Kilo surface (TUI, VS Code
* extension, etc.) that talks to a local `kilo serve` instance.
*
* Backed by a small JSON file under `Global.Path.state` so it survives across CLI
* invocations and is visible to the VS Code extension's embedded `kilo serve` process,
* without requiring a database or per-project scoping.
*/
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { Flock } from "@opencode-ai/core/util/flock"
import { Filesystem } from "@/util/filesystem"

const CLOUD_AGENT_USED = "cloud_agent_used"
const MOBILE_APP_NOTICE_DISMISSED = "mobile_app_notice_dismissed"

export namespace Notices {
const filePath = path.join(Global.Path.state, "notices.json")

Check failure on line 18 in packages/opencode/src/kilocode/notices.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

TypeError: The "paths[0]" property must be of type string

at <anonymous> (/home/runner/_work/kilocode/kilocode/packages/opencode/src/kilocode/notices.ts:18:25) at /home/runner/_work/kilocode/kilocode/packages/opencode/src/kilocode/notices.ts:45:25

Check failure on line 18 in packages/opencode/src/kilocode/notices.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

TypeError: The "paths[0]" property must be of type string

at <anonymous> (/home/runner/_work/kilocode/kilocode/packages/opencode/src/kilocode/notices.ts:18:25) at /home/runner/_work/kilocode/kilocode/packages/opencode/src/kilocode/notices.ts:45:25

Check failure on line 18 in packages/opencode/src/kilocode/notices.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

TypeError: The "paths[0]" property must be of type string

at <anonymous> (/home/runner/_work/kilocode/kilocode/packages/opencode/src/kilocode/notices.ts:18:25) at /home/runner/_work/kilocode/kilocode/packages/opencode/src/kilocode/notices.ts:45:25

Check failure on line 18 in packages/opencode/src/kilocode/notices.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

TypeError: The "paths[0]" property must be of type string

at <anonymous> (/home/runner/_work/kilocode/kilocode/packages/opencode/src/kilocode/notices.ts:18:25) at /home/runner/_work/kilocode/kilocode/packages/opencode/src/kilocode/notices.ts:45:25
const lock = `kilo-notices:${filePath}`

async function read(): Promise<Record<string, boolean | undefined>> {
return Filesystem.readJson<Record<string, boolean | undefined>>(filePath).catch(() => ({}))
}

async function update(patch: Record<string, boolean>): Promise<void> {
await Flock.withLock(lock, async () => {
const current = await read()
await Filesystem.writeJson(filePath, { ...current, ...patch })
})
}

/** Record that the user has enabled a remote/Cloud Agent session relay at least once. */
export async function markCloudAgentUsed(): Promise<void> {
const current = await read()
if (current[CLOUD_AGENT_USED]) return
await update({ [CLOUD_AGENT_USED]: true })
}

/** Whether the "continue in the Kilo mobile app" notice should be shown. */
export async function shouldShowMobileAppNotice(): Promise<boolean> {
const current = await read()
return !!current[CLOUD_AGENT_USED] && !current[MOBILE_APP_NOTICE_DISMISSED]
}

export async function dismissMobileAppNotice(): Promise<void> {
await update({ [MOBILE_APP_NOTICE_DISMISSED]: true })
}
}
27 changes: 27 additions & 0 deletions packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export const RemoveSkillPayload = Schema.Struct({
location: Schema.String,
})

export const MobileAppNotice = Schema.Struct({
show: Schema.Boolean,
})

export const RemoveAgentPayload = Schema.Struct({
name: Schema.String,
})
Expand All @@ -45,6 +49,8 @@ export const KilocodePaths = {
notebookReply: `${root}/notebook/:requestID/reply`,
notebookReject: `${root}/notebook/:requestID/reject`,
sessionModelUsage: `/session/:sessionID/model-usage`,
mobileAppNotice: `${root}/notice/mobile-app`,
dismissMobileAppNotice: `${root}/notice/mobile-app/dismiss`,
} as const

export const KilocodeApi = HttpApi.make("kilocode")
Expand Down Expand Up @@ -145,6 +151,27 @@ export const KilocodeApi = HttpApi.make("kilocode")
description: "Get token usage and direct cost by model for the complete top-level session tree.",
}),
),
HttpApiEndpoint.get("mobileAppNotice", KilocodePaths.mobileAppNotice, {
query: WorkspaceRoutingQuery,
success: described(MobileAppNotice, "Mobile app notice visibility"),
}).annotateMerge(
OpenApi.annotations({
identifier: "kilocode.mobileAppNotice",
summary: "Get mobile app notice visibility",
description:
"Whether to show the 'continue your /remote sessions in the Kilo mobile app' notice. Only true for users who have previously used a Cloud Agent / remote session relay and have not dismissed the notice.",
}),
),
HttpApiEndpoint.post("dismissMobileAppNotice", KilocodePaths.dismissMobileAppNotice, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Notice dismissed"),
}).annotateMerge(
OpenApi.annotations({
identifier: "kilocode.dismissMobileAppNotice",
summary: "Dismiss mobile app notice",
description: "Permanently dismiss the Kilo mobile app promo notice for this machine.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
Expand Down
14 changes: 14 additions & 0 deletions packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import * as KiloAgent from "@/kilocode/agent"
import * as KiloSkill from "@/kilocode/skill-remove"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { HeapSnapshot } from "@/kilocode/cli/heap-snapshot"
import type { RequestID as NotebookRequestID } from "@/kilocode/notebook/protocol"
import { Notebook } from "@/kilocode/notebook/service"
import { Notices } from "@/kilocode/notices"
import { ModelUsage } from "@/kilocode/session/model-usage"
import { InstanceStore } from "@/project/instance-store"
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
Expand Down Expand Up @@ -98,6 +100,16 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
return usage
})

const mobileAppNotice = Effect.fn("KilocodeHttpApi.mobileAppNotice")(function* () {
const show = yield* EffectBridge.fromPromise(() => Notices.shouldShowMobileAppNotice())
return { show }
})

const dismissMobileAppNotice = Effect.fn("KilocodeHttpApi.dismissMobileAppNotice")(function* () {
yield* EffectBridge.fromPromise(() => Notices.dismissMobileAppNotice())
return true
})

return handlers
.handle("heapSnapshot", heapSnapshot)
.handle("agentRequirements", agentRequirements)
Expand All @@ -107,5 +119,7 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
.handle("notebookReply", notebookReply)
.handle("notebookReject", notebookReject)
.handle("sessionModelUsage", sessionModelUsage)
.handle("mobileAppNotice", mobileAppNotice)
.handle("dismissMobileAppNotice", dismissMobileAppNotice)
}),
)
Loading
Loading