-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat: promote Kilo mobile app to Cloud Agent users #11905
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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") | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| // kilocode_change - new file | ||
| /** | ||
| * "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 } }> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: New
Reply with |
||
| 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) | ||
| })() | ||
| }) | ||
| } | ||
| 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
|
||
| 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 }) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SUGGESTION: Unnecessary
kilocode_changemarkerPer
AGENTS.md, files/directories whose path containskilocodenever needkilocode_changemarkers — this file already lives undersrc/kilocode/.... The// kilocode_change - new filemarker here is redundant and can be dropped.Reply with
@kilocode-bot fix itto have Kilo Code address this issue.