From 520dd8c900189ba2dd3230c9c85514bde6f1c83f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 29 Jan 2026 23:34:28 +0200 Subject: [PATCH] style: update section model selection headers to micro typography with uppercase and sticky behavior --- docs/github-features-plan.md | 389 ------ docs/github-usage-report.md | 166 --- .../ui/src/components/chat/ModelControls.tsx | 6 +- .../components/multirun/ModelMultiSelect.tsx | 6 +- .../sections/agents/ModelSelector.tsx | 6 +- pwa_example.md | 1133 ----------------- 6 files changed, 9 insertions(+), 1697 deletions(-) delete mode 100644 docs/github-features-plan.md delete mode 100644 docs/github-usage-report.md delete mode 100644 pwa_example.md diff --git a/docs/github-features-plan.md b/docs/github-features-plan.md deleted file mode 100644 index f5e3b930..00000000 --- a/docs/github-features-plan.md +++ /dev/null @@ -1,389 +0,0 @@ -# GitHub Features Plan (PRD-ish) - -Goal: implement GitHub-powered workflows (PR panel + start sessions from Issue/PR) on top of the existing GitHub auth foundation. - -Non-goals (for this phase) -- Rebuild auth/token storage (already implemented) -- Rebuild worktree lifecycle/cleanup (already implemented) -- Add hard context size caps/fallback logic (explicitly out of scope) - -## Existing Primitives (MUST reuse) - -These already exist; do not reinvent. - -GitHub auth + connected user -- UI: `packages/ui/src/components/sections/openchamber/GitHubSettings.tsx` -- Runtime API: `GitHubAPI` in `packages/ui/src/lib/api/types.ts` -- Web endpoints: `packages/web/server/index.js` (`/api/github/*`) -- Desktop Tauri commands: `packages/desktop/src-tauri/src/commands/github.rs` -- VS Code bridge + storage: `packages/vscode/src/bridge.ts`, `packages/vscode/src/githubAuth.ts` - -Projects and directories -- Projects store (one project == one repo path): `packages/ui/src/stores/useProjectsStore.ts` -- Active project selection is already used across the app; use it to scope all GitHub operations. - -Worktree sessions -- Worktree creation and session wiring: - - `packages/ui/src/lib/worktreeSessionCreator.ts` - - Reuse `createWorktreeSession()` and `createWorktreeSessionForBranch(projectDirectory, branchName)`. -- Worktree cleanup/delete behavior is already present in session deletion flow: - - `packages/ui/src/components/session/SessionDialogs.tsx` - -“Synthetic parts” / hidden context in chat -- SDK supports `TextPartInput.synthetic?: boolean`: - - `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts` -- UI filters synthetic parts out of rendering: - - `packages/ui/src/lib/messages/synthetic.ts` -- Existing “seed new session from assistant answer” uses a hidden preface part: - - `packages/ui/src/stores/useSessionStore.ts` (`createSessionFromAssistantMessage`) - - `packages/ui/src/lib/opencode/client.ts` (`sendMessage({ prefaceText })`) - -Git tab layout -- Git view where PR panel will be inserted: - - `packages/ui/src/components/views/GitView.tsx` -- Changes + Commit already exist; History is below. - -## Guiding Principles - -- One directory/project == one git repo. All GitHub actions are scoped to the active project. -- Do not show synthetic context to the user; only show the human prompt. -- If the user lacks permission for an operation (merge, create branch, etc), degrade to “Open in GitHub”. -- Prefer server-side Octokit for web runtime; for desktop/vscode, implement equivalent runtime commands/bridge handlers. -- Keep UI consistent with existing patterns (provider OAuth, worktree sessions, commit message generation). - -## Feature A: Git Tab PR Panel (Create / Status / Merge) - -Status: implemented. - -### Intent -While working on a feature branch, show PR status and actions inside the Git tab, without leaving the app. - -### Placement -Implemented between Commit and History in `packages/ui/src/components/views/GitView.tsx`. - -### Visibility Rules -- Show only if: - - repo is detected (`isGitRepo === true`) - - branch is not the “base branch” - -Base branch source (reuse existing config): -- `activeProject.worktreeDefaults.baseBranch` from `packages/ui/src/stores/useProjectsStore.ts` -- fallback default: `main` - -### UI States -1) GitHub not connected -- Show CTA: “Connect GitHub” (link to Settings -> OpenChamber -> GitHub). - -2) Connected but repo not resolvable to GitHub -- Show “Open remote in browser” if remote URL exists. -- Show error text explaining remote must be GitHub. - -3) PR does not exist for current branch -- Show create form: - - base branch (default: baseBranch) - - title (default from branch name) - - draft toggle - - description textarea - - “Generate description” button (AI) - - “Create PR” button - -4) PR exists -- Show summary: - - state (draft/open/merged) - - PR number + title - - checks summary - - mergeability (if available) - - “Open in GitHub” -- If user has merge permission and PR is mergeable: - - merge method dropdown (merge/squash/rebase) - - “Merge” button -- If PR is draft: - - “Ready” button (mark ready for review) - - Merge disabled until ready -- If cannot merge: - - disable merge button + show “Open in GitHub” - -### AI “Generate description” -Implemented as a PR-specific generator (separate prompt/endpoint/command). - -Inputs: -- base branch ref (prefers `origin/` when available) -- head branch ref -- committed range diff: `git diff ...` (file list from `git diff --name-only ...`) - -Output: -- `title` (<= 80 chars, no commit-style prefixes) -- `body` (GFM markdown sections: Summary/Testing/Notes) - -### Required GitHub API Calls -- Resolve repo from git remote URL (origin) -- Find PR by head branch -- Create PR -- Get PR details + checks -- Merge PR -- Mark PR ready for review (GraphQL) - -Checks logic (implemented): -- prefer GitHub Actions check-runs (`/commits/{sha}/check-runs`) -- fallback to classic commit statuses (`/commits/{sha}/status`) - -### Implementation Notes -- Web runtime should use server endpoints + Octokit (token stays server-side). -- Desktop/vscode should use their runtime handlers (similar to GitHub auth) to avoid exposing token. - -Implemented code pointers -- UI section: `packages/ui/src/components/views/git/PullRequestSection.tsx` -- Web server endpoints: - - `GET /api/github/pr/status` - - `POST /api/github/pr/create` - - `POST /api/github/pr/merge` - - `POST /api/github/pr/ready` -- PR description generator: - - `POST /api/git/pr-description` - - Desktop: `generate_pr_description` - - VS Code: `api:git/pr-description` - -## Feature B: Start Session From GitHub Issue - -Status: implemented. - -### Intent -Create a new session seeded with issue context, without polluting chat with large issue bodies/comments. - -### Entry Point -Project header menu in `packages/ui/src/components/session/SessionSidebar.tsx`. - -Add new item: -- “New session from GitHub issue” - -### Modal UI -Issue picker modal: -- list issues for current repo (open by default) -- search by title/number -- direct input: - - full URL - - `#123` or `123` -- checkbox: “Create in worktree” - -Implementation notes: -- modal layout matches Timeline dialog styling/patterns -- “Open Repo” + per-issue “Open in GitHub” use `` (desktop webview safe) - -### Worktree option -If enabled: -- create a worktree session (reuse `createWorktreeSessionForBranch`) -- branch naming convention: - - `issue--` (slug derived from title) -- base branch: - - `activeProject.worktreeDefaults.baseBranch` - -If disabled: -- create normal session in project root directory. - -### Session Bootstrap (message) -Send a single user message with: -1) Visible text part: concise prompt, e.g. - - “Review the issue; summarize requirements + unknowns; ask clarifying questions; gather needed code context; propose plan + next actions; do not implement until user confirms.” -2) Hidden synthetic parts: issue payload - - issue title/body - - labels, assignees, author - - comments (ordered) - - metadata (repo, number, url) - -This must use SDK-supported `TextPartInput.synthetic = true` so it is not rendered. -Do not invent a new hidden-context mechanism. - -### Required GitHub API Calls -- List issues -- Get issue by number -- List issue comments - -Implemented code pointers -- UI modal: `packages/ui/src/components/session/GitHubIssuePickerDialog.tsx` -- Shared sendMessage synthetic parts: `packages/ui/src/lib/opencode/client.ts` -- Web server endpoints: - - `GET /api/github/issues/list` - - `GET /api/github/issues/get` - - `GET /api/github/issues/comments` -- Desktop Tauri commands: - - `github_issues_list` - - `github_issue_get` - - `github_issue_comments` -- VS Code bridge handlers: - - `api:github/issues:list` - - `api:github/issues:get` - - `api:github/issues:comments` - -## Feature C: Start Session From GitHub PR (with worktree checkout) - -Status: implemented. - -### Intent -Create a session seeded with PR context, with optional worktree checkout of PR branch (including forks). - -### Entry Point -Project header menu in `packages/ui/src/components/session/SessionSidebar.tsx`. - -Add new item: -- “New session from GitHub PR” - -### Modal UI -PR picker modal: -- list open PRs -- search by title/number -- direct input: - - full URL - - `#123` or `123` -- checkbox: “Create session in PR worktree” - -Implementation notes: -- modal layout matches Timeline dialog styling/patterns -- list pagination: “Load more” (page-based, `per_page=50`) -- optional toggle: include full diff in hidden context - -### Worktree behavior -If enabled: -- if PR is from same repo: - - fetch PR head into `FETCH_HEAD` - - create worktree using the PR branch name, starting at `FETCH_HEAD` (does not change main worktree) -- if PR is from fork: - - fetch PR head from fork clone URL into `FETCH_HEAD` - - create worktree using the PR branch name, starting at `FETCH_HEAD` - -Fallbacks: -- if fetch/remote fails or permission denied: - - still create a normal session with PR context - - show toast with error; user can open PR in GitHub - -### Session Bootstrap (message) -Same synthetic-parts approach as Issues. - -Hidden parts should include: -- PR title/body -- PR comments + review comments -- changed files list -- optionally full diff (explicitly no caps) -- checks/status summary - -Visible prompt text should instruct: -- review PR intent; call out intent/implementation mismatch -- identify risks + missing pieces -- gather needed repo context; no speculation; ask for missing info -- propose a plan + next actions; do not implement until user confirms - -Implemented code pointers -- UI modal: `packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx` -- Web server endpoints: - - `GET /api/github/pulls/list` - - `GET /api/github/pulls/context` -- Desktop Tauri commands: - - `github_prs_list` - - `github_pr_context` -- VS Code bridge handlers: - - `api:github/pulls:list` - - `api:github/pulls:context` - -## Feature E: PR Context Helpers (Checks/Comments -> Chat) - -Status: implemented. - -Intent -Reduce PR iteration loop time by letting the user add targeted PR signal (failed checks, review feedback) into the next chat message without polluting visible chat. - -Placement -Inside the existing Git tab PR panel (`packages/ui/src/components/views/git/PullRequestSection.tsx`). - -UI -- “Send failed checks to chat” (only if failures exist) -- “Send PR comments to chat” (issue comments + review comments) -- “Check details” dialog (shows check runs, app name, and GitHub Actions job steps when available) -- “Refresh checks” affordance - -Behavior -- On click, sends a new user message in the current session (and switches to Chat tab). -- Message contains a short visible prompt plus hidden synthetic parts. -- Failed checks payload should include: check name, status/conclusion, details URL, and any available summary/text. -- When available, include GitHub Actions job + step breakdown and the check app name. -- Comments payload should include: author, body, file/path + line when available, and comment URL. -- Keep visible user prompt short; include only human intent. - -Implementation notes -- Reuse Feature C PR context endpoint (`/api/github/pulls/context`) as the single source for comments/files/checks/diff. -- Prefer a compact checks rollup (passed/total) with optional expand into failing checks. - -Implemented code pointers -- UI: `packages/ui/src/components/views/git/PullRequestSection.tsx` - -### Required GitHub API Calls -- List PRs -- Get PR -- List issue comments for PR -- List review comments -- List files -- Get checks/status - -## Cross-cutting: “Synthetic Parts” Sending API - -Current behavior: -- `opencodeClient.sendMessage()` supports `prefaceText` which becomes a separate `TextPartInput`. -- There is no first-class way (yet) to mark arbitrary parts as `synthetic: true` from callsites. - -Required change (shared for Features B/C): -- Extend `opencodeClient.sendMessage()` (in `packages/ui/src/lib/opencode/client.ts`) to support synthetic parts. - -Recommended minimal API change: -- allow `prefaceTextSynthetic?: boolean` (default true when used for hidden context) -- allow `additionalParts?: Array<{ text: string; synthetic?: boolean; files?: ... }>` -- ensure generated `TextPartInput` includes `synthetic` when requested - -This should reuse the existing filtering/rendering logic (no new UI hacks). - -## Cross-cutting: Repo Resolution - -Need a single helper to map current project repo -> GitHub owner/repo. - -Inputs: -- project directory root -- git remote URL (origin) - -Behavior: -- support common GitHub URL formats: - - `git@github.com:OWNER/REPO.git` - - `https://github.com/OWNER/REPO.git` - - `https://github.com/OWNER/REPO` - -Output: -- `{ owner, repo }` or null - -Use this for all GitHub feature endpoints. - -## Cross-cutting: Permission / Fallback Rules - -- Merge button enabled only if merge endpoint succeeds or mergeability indicates allowed. -- If not allowed: - - show “Open in GitHub” as primary action -- For PR worktrees from forks: - - if remote add/fetch fails => create normal session + “Open in GitHub” - -## Work Breakdown (Suggested Order) - -Phase 1: Shared plumbing -1) Repo resolution helper (remote URL -> owner/repo) -2) New message sending helper supporting `synthetic: true` parts -3) GitHub endpoints/commands for issue + PR fetch (read-only) - -Phase 2: Session bootstrap flows -4) Issue picker modal + session bootstrap -5) PR picker modal + session bootstrap -6) PR worktree checkout (fork support) - -Phase 3: Git tab PR panel -7) PR detect/status in Git tab -8) Create PR from branch -9) AI generate PR description -10) Merge (with fallback) - -## Open Questions (for later) - -- PR description generator prompt format: do we want the exact same “highlights” UI as commit gen, or a single-shot body generation? -- Worktree naming collision strategy for PR-based worktrees (owner/ref collisions) beyond current `sanitizeWorktreeSlug`. diff --git a/docs/github-usage-report.md b/docs/github-usage-report.md deleted file mode 100644 index c88262ac..00000000 --- a/docs/github-usage-report.md +++ /dev/null @@ -1,166 +0,0 @@ -# GitHub Integration (Auth Foundation) - -This repo now has a GitHub auth foundation intended to be reused by all future GitHub features (PRs/issues/worktrees/etc). - -It provides: -- GitHub OAuth Device Flow connect UX -- persistent token storage per runtime -- a small runtime API surface for UI -- server-side Octokit usage (web runtime) - -## Scopes - -Default scopes requested: - -``` -repo read:org workflow read:user user:email -``` - -Notes: -- Email is fetched from `/user` when available, otherwise `/user/emails` (requires `user:email`). -- Actions performed via this OAuth token are performed “as the user” (not a bot), but the OAuth App is visible under GitHub “Authorized OAuth Apps”. - -## UI - -Settings entry: -- `packages/ui/src/components/sections/openchamber/GitHubSettings.tsx` - -Behavior: -- shows connected user card (avatar + name/email/login) -- Connect triggers Device Flow and polls until authorized -- Disconnect clears the stored token - -## Runtime API (UI) - -`RuntimeAPIs.github` is optional (some environments may not expose it). - -Types: -- `packages/ui/src/lib/api/types.ts` (`GitHubAPI`, `GitHubAuthStatus`, `GitHubDeviceFlowStart`, `GitHubDeviceFlowComplete`) - -Methods: -- `authStatus(): { connected, user?, scope? }` -- `authStart(): { deviceCode, userCode, verificationUri, verificationUriComplete?, expiresIn, interval, scope? }` -- `authComplete(deviceCode): { connected: true, user, scope? } | { connected: false, status?, error? }` -- `authDisconnect(): { removed: boolean }` -- `me?(): user` (optional, mostly for debugging) - -Implementations: -- Web: `packages/web/src/api/github.ts` -- Desktop: `packages/desktop/src/api/github.ts` (calls Tauri commands) -- VS Code: `packages/vscode/webview/api/github.ts` (bridge messages) - -## Web Runtime (Express server) - -Endpoints (JSON): - -- `GET /api/github/auth/status` - - returns `{ connected: false }` or `{ connected: true, user, scope }` - -- `POST /api/github/auth/start` - - returns device flow payload: - - `{ deviceCode, userCode, verificationUri, verificationUriComplete?, expiresIn, interval, scope }` - -- `POST /api/github/auth/complete` - - request: `{ deviceCode }` - - returns either pending or success: - - pending: `{ connected: false, status, error }` - - success: `{ connected: true, user, scope }` - -- `DELETE /api/github/auth` - - clears stored token - - returns `{ success: true, removed: boolean }` - -- `GET /api/github/me` - - returns the authenticated user summary - -Code: -- endpoints: `packages/web/server/index.js` -- token store + config defaults: `packages/web/server/lib/github-auth.js` -- Octokit factory: `packages/web/server/lib/github-octokit.js` -- device flow helpers: `packages/web/server/lib/github-device-flow.js` - -## Desktop Runtime (Tauri) - -Tauri commands: -- `github_auth_status` -- `github_auth_start` -- `github_auth_complete` (param: `deviceCode`) -- `github_auth_disconnect` -- `github_me` - -Code: -- `packages/desktop/src-tauri/src/commands/github.rs` -- wired in invoke handler: `packages/desktop/src-tauri/src/main.rs` - -## VS Code Runtime - -Bridge message types handled in extension: -- `api:github/auth:status` -- `api:github/auth:start` -- `api:github/auth:complete` -- `api:github/auth:disconnect` -- `api:github/me` - -Code: -- storage + device flow + `/user` fetch: `packages/vscode/src/githubAuth.ts` -- bridge handlers: `packages/vscode/src/bridge.ts` - -## Token Storage - -- Web/server runtime: `~/.config/openchamber/github-auth.json` - - file mode `0600` best-effort - -- Desktop runtime: `~/.config/openchamber/github-auth.json` - - file mode `0600` best-effort - -- VS Code runtime: `${extensionGlobalStorage}/github-auth.json` - - file mode `0600` best-effort - -Stored fields (current shape; can evolve): - -```json -{ - "accessToken": "…", - "scope": "…", - "tokenType": "bearer", - "createdAt": 1730000000000, - "user": { - "login": "…", - "id": 123, - "avatarUrl": "…", - "name": "…", - "email": "…" - } -} -``` - -## Official OAuth App - -Default OAuth client id is baked in: -- `Ov23liNd8TxDcMXtAHHM` - -Overrides: -- Web/server: `OPENCHAMBER_GITHUB_CLIENT_ID` (env) -- Scopes override (web/server): `OPENCHAMBER_GITHUB_SCOPES` (env) - -Note: UI editing of client id/scopes was intentionally removed to reduce user confusion. - -## How to Use in New Features - -Preferred pattern: -- UI triggers new feature flows. -- Backend (web server or desktop/vscode runtime command/bridge) performs GitHub API calls using the stored token. -- Do not expose the token to the UI. - -Web/server feature endpoints should: -- require `{ connected: true }` state (return 401 if not connected) -- use Octokit with `auth` set to stored token -- accept repo/issue/pr identifiers from UI and fetch needed context - -Future “context bootstrap” idea: -- Add endpoints that take `{ owner, repo, number }` and return: - - issue/PR body - - comments - - changed files (PR) - - diff/patch summary -Then UI can start a session with a prefilled prompt. diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 263fb7ab..6e97a12e 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -2028,7 +2028,7 @@ export const ModelControls: React.FC = ({ className }) => { {/* Favorites Section */} {filteredFavorites.length > 0 && ( <> - + Favorites @@ -2043,7 +2043,7 @@ export const ModelControls: React.FC = ({ className }) => { {filteredRecents.length > 0 && ( <> {filteredFavorites.length > 0 && } - + Recent @@ -2063,7 +2063,7 @@ export const ModelControls: React.FC = ({ className }) => { {filteredProviders.map((provider, index) => ( {index > 0 && } - + = ({ {/* Favorites Section */} {filteredFavorites.length > 0 && ( <> -
+
Favorites
@@ -426,7 +426,7 @@ export const ModelMultiSelect: React.FC = ({ {filteredRecents.length > 0 && ( <> {filteredFavorites.length > 0 &&
} -
+
Recent
@@ -446,7 +446,7 @@ export const ModelMultiSelect: React.FC = ({ {filteredProviders.map((provider, index) => ( {index > 0 &&
} -
+
= ({ {/* Favorites Section */} {filteredFavorites.length > 0 && ( <> - + Favorites @@ -633,7 +633,7 @@ export const ModelSelector: React.FC = ({ {filteredRecents.length > 0 && ( <> {filteredFavorites.length > 0 && } - + Recent @@ -653,7 +653,7 @@ export const ModelSelector: React.FC = ({ {filteredProviders.map((provider, index) => ( {index > 0 && } - + { - const settingsFile = getSettingsFile(dataDir) - const result = await getOrCreateSettingsValue({ - settingsFile, - readValue: (settings) => { - if (settings.vapidKeys?.publicKey && settings.vapidKeys?.privateKey) { - return { value: settings.vapidKeys } - } - return null - }, - writeValue: (settings, value) => { - settings.vapidKeys = value - }, - generate: () => { - const generated = generateVAPIDKeys() - return { - publicKey: generated.publicKey, - privateKey: generated.privateKey - } - } - }) - - return result.value -} -``` - -**Process:** -- Keys generated once on startup using `web-push` -- Keys persisted to settings file for later reuse -- Only public key exposed to frontend - ---- - -### 1.2 Push Service - -**Location:** `server/src/push/pushService.ts` - -```typescript -import * as webPush from 'web-push' - -export type PushPayload = { - title: string - body: string - tag?: string - data?: { - type: string - sessionId: string - url: string - } -} - -export class PushService { - constructor( - private readonly vapidKeys: VapidKeys, - private readonly subject: string, - private readonly store: Store - ) { - webPush.setVapidDetails(this.subject, this.vapidKeys.publicKey, this.vapidKeys.privateKey) - } - - async sendToNamespace(namespace: string, payload: PushPayload): Promise { - const subscriptions = this.store.push.getPushSubscriptionsByNamespace(namespace) - if (subscriptions.length === 0) { - return - } - - const body = JSON.stringify(payload) - await Promise.all(subscriptions.map((subscription) => { - return this.sendToSubscription(namespace, subscription, body) - })) - } - - private async sendToSubscription( - namespace: string, - subscription: StoredSubscription, - body: string - ): Promise { - const pushSubscription: PushSubscription = { - endpoint: subscription.endpoint, - keys: { - p256dh: subscription.p256dh, - auth: subscription.auth - } - } - - try { - await webPush.sendNotification(pushSubscription, body) - } catch (error) { - const statusCode = typeof (error as { statusCode?: unknown }).statusCode === 'number' - ? (error as { statusCode: number }).statusCode - : null - - if (statusCode === 410) { - // Subscription expired - remove it - this.store.push.removePushSubscription(namespace, subscription.endpoint) - return - } - - console.error('[PushService] Failed to send notification:', error) - } - } -} -``` - -**Process:** -1. Gets subscriptions from database by namespace -2. Sends notification to all subscribers using web-push -3. Removes subscriptions that return 410 Gone -4. Handles other errors gracefully - ---- - -### 1.3 Push Notification Channel - -**Location:** `server/src/push/pushNotificationChannel.ts` - -```typescript -import type { Session } from '../sync/syncEngine' -import type { NotificationChannel } from '../notifications/notificationTypes' -import { getAgentName, getSessionName } from '../notifications/sessionInfo' -import type { SSEManager } from '../sse/sseManager' -import type { VisibilityTracker } from '../visibility/visibilityTracker' -import type { PushPayload, PushService } from './pushService' - -export class PushNotificationChannel implements NotificationChannel { - constructor( - private readonly pushService: PushService, - private readonly sseManager: SSEManager, - private readonly visibilityTracker: VisibilityTracker, - _appUrl: string - ) {} - - async sendPermissionRequest(session: Session): Promise { - if (!session.active) { - return - } - - const name = getSessionName(session) - const request = session.agentState?.requests - ? Object.values(session.agentState.requests)[0] - : null - const toolName = request?.tool ? ` (${request.tool})` : '' - - const payload: PushPayload = { - title: 'Permission Request', - body: `${name}${toolName}`, - tag: `permission-${session.id}`, - data: { - type: 'permission-request', - sessionId: session.id, - url: this.buildSessionPath(session.id) - } - } - - // Try SSE first (for visible sessions) - const url = payload.data?.url ?? this.buildSessionPath(session.id) - if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { - const delivered = await this.sseManager.sendToast(session.namespace, { - type: 'toast', - data: { - title: payload.title, - body: payload.body, - sessionId: session.id, - url - } - }) - if (delivered > 0) { - return - } - } - - // Fallback to push notification - await this.pushService.sendToNamespace(session.namespace, payload) - } - - async sendReady(session: Session): Promise { - if (!session.active) { - return - } - - const agentName = getAgentName(session) - const name = getSessionName(session) - - const payload: PushPayload = { - title: 'Ready for input', - body: `${agentName} is waiting in ${name}`, - tag: `ready-${session.id}`, - data: { - type: 'ready', - sessionId: session.id, - url: this.buildSessionPath(session.id) - } - } - - // Try SSE first - const url = payload.data?.url ?? this.buildSessionPath(session.id) - if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { - const delivered = await this.sseManager.sendToast(session.namespace, { - type: 'toast', - data: { - title: payload.title, - body: payload.body, - sessionId: session.id, - url - } - }) - if (delivered > 0) { - return - } - } - - // Fallback to push notification - await this.pushService.sendToNamespace(session.namespace, payload) - } - - private buildSessionPath(sessionId: string): string { - return `/sessions/${sessionId}` - } -} -``` - -**Strategy:** -- **SSE Priority**: Checks if session is visible in current tab/window (SSE) -- **Push Fallback**: If not visible, sends background notification via VAPID -- **Tagging**: Uses session ID for grouping same-type notifications - ---- - -### 1.4 Notification Hub - -**Location:** `server/src/notifications/notificationHub.ts` - -```typescript -import type { Session, SyncEvent } from '../sync/syncEngine' -import type { NotificationChannel, NotificationHubOptions } from './notificationTypes' -import { extractMessageEventType } from './eventParsing' - -export class NotificationHub { - private readonly channels: NotificationChannel[] - private readonly readyCooldownMs: number - private readonly permissionDebounceMs: number - private readonly lastKnownRequests: Map> = new Map() - private readonly notificationDebounce: Map = new Map() - private readonly lastReadyNotificationAt: Map = new Map() - - constructor( - private readonly syncEngine: SyncEngine, - channels: NotificationChannel[], - options?: NotificationHubOptions - ) { - this.channels = channels - this.readyCooldownMs = options?.readyCooldownMs ?? 5000 - this.permissionDebounceMs = options?.permissionDebounceMs ?? 500 - this.unsubscribeSyncEvents = this.syncEngine.subscribe((event) => { - this.handleSyncEvent(event) - }) - } - - private handleSyncEvent(event: SyncEvent): void { - // Permission notifications - if ((event.type === 'session-updated' || event.type === 'session-added') && event.sessionId) { - const session = this.syncEngine.getSession(event.sessionId) - if (!session || !session.active) { - this.clearSessionState(event.sessionId) - return - } - this.checkForPermissionNotification(session) - return - } - - if (event.type === 'session-removed' && event.sessionId) { - this.clearSessionState(event.sessionId) - return - } - - // Ready notifications - if (event.type === 'message-received' && event.sessionId) { - const eventType = extractMessageEventType(event) - if (eventType === 'ready') { - this.sendReadyNotification(event.sessionId).catch((error) => { - console.error('[NotificationHub] Failed to send ready notification:', error) - }) - } - } - } - - private checkForPermissionNotification(session: Session): void { - const requests = session.agentState?.requests - - if (requests == null) { - return - } - - const newRequestIds = new Set(Object.keys(requests)) - const oldRequestIds = this.lastKnownRequests.get(session.id) || new Set() - - let hasNewRequests = false - for (const requestId of newRequestIds) { - if (!oldRequestIds.has(requestId)) { - hasNewRequests = true - break - } - } - - this.lastKnownRequests.set(session.id, newRequestIds) - - if (!hasNewRequests) { - return - } - - // Debounce permission notifications - const existingTimer = this.notificationDebounce.get(session.id) - if (existingTimer) { - clearTimeout(existingTimer) - } - - const timer = setTimeout(() => { - this.notificationDebounce.delete(session.id) - this.sendPermissionNotification(session.id).catch((error) => { - console.error('[NotificationHub] Failed to send permission notification:', error) - }) - }, this.permissionDebounceMs) - - this.notificationDebounce.set(session.id, timer) - } - - private clearSessionState(sessionId: string): void { - const existingTimer = this.notificationDebounce.get(sessionId) - if (existingTimer) { - clearTimeout(existingTimer) - this.notificationDebounce.delete(sessionId) - } - this.lastKnownRequests.delete(sessionId) - this.lastReadyNotificationAt.delete(sessionId) - } - - private getNotifiableSession(sessionId: string): Session | null { - const session = this.syncEngine.getSession(sessionId) - if (!session || !session.active) { - return null - } - return session - } -} -``` - -**Features:** -- **Event-Based**: Listens to sync events from SyncEngine -- **Debouncing**: - - Permission requests: 500ms debounce - - Ready notifications: 5s cooldown -- **State Tracking**: Remembers last-known requests to detect new ones -- **Multi-Channel**: Forwards to all registered channels - ---- - -### 1.5 Subscription Storage - -**Location:** `server/src/store/pushSubscriptions.ts` - -```typescript -import type { Database } from 'bun:sqlite' -import type { StoredPushSubscription } from './types' - -type DbPushSubscriptionRow = { - id: number - namespace: string - endpoint: string - p256dh: string - auth: string - created_at: number -} - -export function addPushSubscription( - db: Database, - namespace: string, - subscription: { endpoint: string; p256dh: string; auth: string } -): void { - const now = Date.now() - db.prepare(` - INSERT INTO push_subscriptions ( - namespace, endpoint, p256dh, auth, created_at - ) VALUES ( - @namespace, @endpoint, @p256dh, @auth, @created_at - ) - ON CONFLICT(namespace, endpoint) - DO UPDATE SET - p256dh = excluded.p256dh, - auth = excluded.auth, - created_at = excluded.created_at - `).run({ - namespace, - endpoint: subscription.endpoint, - p256dh: subscription.p256dh, - auth: subscription.auth, - created_at: now - }) -} - -export function removePushSubscription(db: Database, namespace: string, endpoint: string): void { - db.prepare( - 'DELETE FROM push_subscriptions WHERE namespace = ? AND endpoint = ?' - ).run(namespace, endpoint) -} - -export function getPushSubscriptionsByNamespace( - db: Database, - namespace: string -): StoredPushSubscription[] { - const rows = db.prepare( - 'SELECT * FROM push_subscriptions WHERE namespace = ? ORDER BY created_at DESC' - ).all(namespace) as DbPushSubscriptionRow[] - return rows.map(toStoredPushSubscription) -} -``` - -**Storage Strategy:** -- SQLite database with namespace-based isolation -- Upsert on duplicate (updates keys if same endpoint) -- Timestamps for ordering - ---- - -### 1.6 Push API Routes - -**Location:** `server/src/web/routes/push.ts` - -```typescript -import { Hono } from 'hono' -import { z } from 'zod' -import type { Store } from '../../store' -import type { WebAppEnv } from '../middleware/auth' - -const subscriptionSchema = z.object({ - endpoint: z.string().min(1), - keys: z.object({ - p256dh: z.string().min(1), - auth: z.string().min(1) - }) -}) - -const unsubscribeSchema = z.object({ - endpoint: z.string().min(1) -}) - -export function createPushRoutes(store: Store, vapidPublicKey: string): Hono { - const app = new Hono() - - app.get('/push/vapid-public-key', (c) => { - return c.json({ publicKey: vapidPublicKey }) - }) - - app.post('/push/subscribe', async (c) => { - const json = await c.req.json().catch(() => null) - const parsed = subscriptionSchema.safeParse(json) - if (!parsed.success) { - return c.json({ error: 'Invalid body' }, 400) - } - - const namespace = c.get('namespace') - const { endpoint, keys } = parsed.data - store.push.addPushSubscription(namespace, { - endpoint, - p256dh: keys.p256dh, - auth: keys.auth - }) - - return c.json({ ok: true }) - }) - - app.delete('/push/subscribe', async (c) => { - const json = await c.req.json().catch(() => null) - const parsed = unsubscribeSchema.safeParse(json) - if (!parsed.success) { - return c.json({ error: 'Invalid body' }, 400) - } - - const namespace = c.get('namespace') - store.push.removePushSubscription(namespace, parsed.data.endpoint) - return c.json({ ok: true }) - }) - - return app -} -``` - -**API Endpoints:** -- `GET /push/vapid-public-key` - Exposes VAPID public key -- `POST /push/subscribe` - Register push subscription -- `DELETE /push/subscribe` - Unsubscribe - ---- - -### 1.7 Service Worker - -**Location:** `web/src/sw.ts` - -```typescript -/// -import { precacheAndRoute } from 'workbox-precaching' -import { registerRoute } from 'workbox-routing' -import { CacheFirst, NetworkFirst } from 'workbox-strategies' -import { ExpirationPlugin } from 'workbox-expiration' - -declare const self: ServiceWorkerGlobalScope & { - __WB_MANIFEST: Array -} - -type PushPayload = { - title: string - body?: string - icon?: string - badge?: string - tag?: string - data?: { - type?: string - sessionId?: string - url?: string - } -} - -precacheAndRoute(self.__WB_MANIFEST) - -// Cache API responses -registerRoute( - ({ url }) => url.pathname === '/api/sessions', - new NetworkFirst({ - cacheName: 'api-sessions', - networkTimeoutSeconds: 10, - plugins: [ - new ExpirationPlugin({ - maxEntries: 10, - maxAgeSeconds: 60 * 5 - }) - ] - }) -) - -// Handle push notifications -self.addEventListener('push', (event) => { - const payload = event.data?.json() as PushPayload | undefined - if (!payload) { - return - } - - const title = payload.title || 'HAPI' - const body = payload.body ?? '' - const icon = payload.icon ?? '/pwa-192x192.png' - const badge = payload.badge ?? '/pwa-64x64.png' - const data = payload.data - const tag = payload.tag - - event.waitUntil( - self.registration.showNotification(title, { - body, - icon, - badge, - data, - tag - }) - ) -}) - -// Handle notification clicks -self.addEventListener('notificationclick', (event) => { - event.notification.close() - const data = event.notification.data as { url?: string } | undefined - const url = data?.url ?? '/' - event.waitUntil(self.clients.openWindow(url)) -}) -``` - -**Features:** -- Uses Workbox for caching strategies -- Push event handler: Shows notification with custom payload -- Click handler: Opens app at specific URL (deep linking) - ---- - -## 2. Frontend Implementation - -### 2.1 React Hook for Push Management - -**Location:** `web/src/hooks/usePushNotifications.ts` - -```typescript -import { useCallback, useEffect, useState } from 'react' -import type { ApiClient } from '@/api/client' - -function isPushSupported(): boolean { - return typeof window !== 'undefined' - && 'serviceWorker' in navigator - && 'PushManager' in window - && 'Notification' in window -} - -function base64UrlToUint8Array(base64Url: string): Uint8Array { - const padding = '='.repeat((4 - (base64Url.length % 4)) % 4) - const base64 = (base64Url + padding) - .replace(/-/g, '+') - .replace(/_/g, '/') - const raw = atob(base64) - const output = new Uint8Array(raw.length) - for (let i = 0; i < raw.length; i += 1) { - output[i] = raw.charCodeAt(i) - } - return output -} - -export function usePushNotifications(api: ApiClient | null) { - const [isSupported, setIsSupported] = useState(false) - const [permission, setPermission] = useState('default') - const [isSubscribed, setIsSubscribed] = useState(false) - - const refreshSubscription = useCallback(async () => { - if (!isPushSupported()) { - setIsSupported(false) - setIsSubscribed(false) - return - } - - setIsSupported(true) - setPermission(Notification.permission) - - if (Notification.permission !== 'granted') { - setIsSubscribed(false) - return - } - - const registration = await navigator.serviceWorker.ready - const subscription = await registration.pushManager.getSubscription() - setIsSubscribed(Boolean(subscription)) - }, []) - - useEffect(() => { - void refreshSubscription() - }, [refreshSubscription]) - - const requestPermission = useCallback(async (): Promise => { - if (!isPushSupported()) { - return false - } - - const result = await Notification.requestPermission() - setPermission(result) - if (result !== 'granted') { - setIsSubscribed(false) - } - return result === 'granted' - }, []) - - const subscribe = useCallback(async (): Promise => { - if (!api || !isPushSupported()) { - return false - } - - if (Notification.permission !== 'granted') { - setPermission(Notification.permission) - return false - } - - try { - const registration = await navigator.serviceWorker.ready - const existing = await registration.pushManager.getSubscription() - const { publicKey } = await api.getPushVapidPublicKey() - const applicationServerKey = base64UrlToUint8Array(publicKey).buffer as ArrayBuffer - const subscription = existing ?? await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey - }) - - const json = subscription.toJSON() - const keys = json.keys - if (!json.endpoint || !keys?.p256dh || !keys.auth) { - return false - } - - await api.subscribePushNotifications({ - endpoint: json.endpoint, - keys: { - p256dh: keys.p256dh, - auth: keys.auth - } - }) - setIsSubscribed(true) - return true - } catch (error) { - console.error('[PushNotifications] Failed to subscribe:', error) - return false - } - }, [api]) - - const unsubscribe = useCallback(async (): Promise => { - if (!api || !isPushSupported()) { - return false - } - - try { - const registration = await navigator.serviceWorker.ready - const subscription = await registration.pushManager.getSubscription() - if (!subscription) { - setIsSubscribed(false) - return true - } - - const endpoint = subscription.endpoint - const success = await subscription.unsubscribe() - await api.unsubscribePushNotifications({ endpoint }) - setIsSubscribed(false) - return success - } catch (error) { - console.error('[PushNotifications] Failed to unsubscribe:', error) - return false - } - }, [api]) - - return { - isSupported, - permission, - isSubscribed, - requestPermission, - subscribe, - unsubscribe - } -} -``` - -**Process:** -1. Check browser support -2. Request permission on user action -3. Subscribe using PushManager with VAPID key -4. Send subscription details to server -5. Unsubscribe by removing subscription - ---- - -### 2.2 PWA Configuration - -**Location:** `web/vite.config.ts` - -```typescript -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import { VitePWA } from 'vite-plugin-pwa' -import { resolve } from 'node:path' - -export default defineConfig({ - plugins: [ - react(), - VitePWA({ - registerType: 'autoUpdate', - includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png', 'mask-icon.svg'], - strategies: 'injectManifest', - srcDir: 'src', - filename: 'sw.ts', - manifest: { - name: 'HAPI', - short_name: 'HAPI', - description: 'AI-powered development assistant', - theme_color: '#ffffff', - background_color: '#ffffff', - display: 'standalone', - orientation: 'portrait', - scope: base, - start_url: base, - icons: [ - { - src: 'pwa-64x64.png', - sizes: '64x64', - type: 'image/png' - }, - { - src: 'pwa-192x192.png', - sizes: '192x192', - type: 'image/png' - }, - { - src: 'pwa-512x512.png', - sizes: '512x512', - type: 'image/png' - }, - { - src: 'maskable-icon-512x512.png', - sizes: '512x512', - type: 'image/png', - purpose: 'maskable' - } - ] - }, - injectManifest: { - globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'] - }, - devOptions: { - enabled: true, - type: 'module' - } - }) - ], - // ... -}) -``` - -**Configuration:** -- **Auto-update**: Service worker auto-updates every load -- **Manifest**: Defines PWA metadata and icons -- **Icons**: Multiple sizes including maskable - ---- - -### 2.3 Service Worker Registration - -**Location:** `web/src/main.tsx` - -```typescript -import { registerSW } from 'virtual:pwa-register' - -async function bootstrap() { - // ... other initialization - - const updateSW = registerSW({ - onNeedRefresh() { - if (confirm('New version available! Reload to update?')) { - updateSW(true) - } - }, - onOfflineReady() { - console.log('App ready for offline use') - }, - onRegistered(registration) { - if (registration) { - // Auto-update every hour - setInterval(() => { - registration.update() - }, 60 * 60 * 1000) - } - }, - onRegisterError(error) { - console.error('SW registration error:', error) - } - }) - - // ... -} - -bootstrap() -``` - ---- - -### 2.4 App Integration - -**Location:** `web/src/App.tsx` - -```typescript -const { isSupported: isPushSupported, permission: pushPermission, requestPermission, subscribe } = usePushNotifications(api) - -useEffect(() => { - if (!api || !token) { - pushPromptedRef.current = false - return - } - if (isTelegramApp() || !isPushSupported) { - return - } - if (pushPromptedRef.current) { - return - } - pushPromptedRef.current = true - - const run = async () => { - if (pushPermission === 'granted') { - await subscribe() - return - } - if (pushPermission === 'default') { - const granted = await requestPermission() - if (granted) { - await subscribe() - } - } - } - - void run() -}, [api, isPushSupported, pushPermission, requestPermission, subscribe, token]) -``` - -**Strategy:** -- Prompt user for permission once per session -- Auto-subscribe if permission granted -- Don't prompt in Telegram environment (uses built-in notifications) - ---- - -## 3. Full Data Flow - -### 3.1 Subscription Flow - -``` -User action (App.tsx) - ↓ -requestPermission() (usePushNotifications.ts) - ↓ -Browser prompt - ↓ -User approves → Notification.permission = 'granted' - ↓ -subscribe() (usePushNotifications.ts) - ↓ -navigator.serviceWorker.ready - ↓ -registration.pushManager.subscribe() - ↓ -Get subscription keys (endpoint, p256dh, auth) - ↓ -POST /api/push/subscribe (api.client.ts) - ↓ -Server stores in database (pushSubscriptions.ts) -``` - ---- - -### 3.2 Notification Delivery Flow - -``` -Event occurs (session state change) - ↓ -NotificationHub.handleSyncEvent() - ↓ -checkForPermissionNotification() / sendReadyNotification() - ↓ -NotificationHub.notifyPermission() / notifyReady() - ↓ -PushNotificationChannel.sendPermissionRequest() / sendReady() - ↓ -Check visibility (VisibilityTracker) - ↓ - ├─ Visible → SSE toast (immediate) - ↓ - └─ Not visible → Push notification - ↓ - PushService.sendToNamespace() - ↓ - Get subscriptions from DB - ↓ - web-push.sendNotification() - ↓ - Device receives push - ↓ - Service Worker push event - ↓ - showNotification() (sw.ts) - ↓ - User sees notification - ↓ - Click handler opens app -``` - ---- - -## 4. Key Implementation Details - -### 4.1 VAPID Authentication - -```typescript -// Server -webPush.setVapidDetails( - subject, // mailto:admin@hapi.run - publicKey, // From getOrCreateVapidKeys() - privateKey // From getOrCreateVapidKeys() -) - -// Frontend -const { publicKey } = await api.getPushVapidPublicKey() -const applicationServerKey = base64UrlToUint8Array(publicKey).buffer -``` - -**Why VAPID:** -- Validates sender (prevents spam) -- Required for service worker push to work -- Uses asymmetric crypto (public/private key pair) - ---- - -### 4.2 Tagging Strategy - -```typescript -{ - title: 'Ready for input', - tag: `ready-${session.id}`, // Group notifications - data: { - type: 'ready', - sessionId: session.id, - url: '/sessions/123' - } -} -``` - -**Benefits:** -- Same-type notifications combine into single item -- User can dismiss all similar notifications at once -- Deep linking via URL - ---- - -### 4.3 Error Handling - -```typescript -// Service worker -self.addEventListener('push', (event) => { - event.waitUntil( - self.registration.showNotification(title, options) - ) -}) - -// Push service -try { - await webPush.sendNotification(pushSubscription, body) -} catch (error) { - if (statusCode === 410) { - removeSubscription() // Subscription expired - } -} -``` - ---- - -### 4.4 Database Schema - -```sql -CREATE TABLE push_subscriptions ( - id INTEGER PRIMARY KEY, - namespace TEXT NOT NULL, - endpoint TEXT NOT NULL UNIQUE, - p256dh TEXT NOT NULL, - auth TEXT NOT NULL, - created_at INTEGER NOT NULL -); -``` - ---- - -## 5. Testing Checklist - -### Server-side -- [ ] VAPID keys generated correctly -- [ ] Subscription stored in DB -- [ ] Notification sent to valid subscription -- [ ] 410 Gone handled (remove expired subscriptions) -- [ ] Routes return proper error codes - -### Frontend -- [ ] Push supported check works -- [ ] Permission request triggered correctly -- [ ] Subscription created with correct keys -- [ ] Keys sent to server -- [ ] Unsubscribe works -- [ ] Deep linking opens correct URL - -### Integration -- [ ] Permission notification sent when new request appears -- [ ] Ready notification sent after AI responds -- [ ] Debouncing prevents spam -- [ ] Fallback to SSE when visible -- [ ] Push used when not visible - ---- - -## 6. Common Pitfalls - -1. **VAPID Key Mismatch** - Frontend public key must match server private key -2. **Wrong Content-Type** - Push payload must be string (not JSON object) -3. **Missing Icon** - Notification needs icon to display correctly -4. **Service Worker Not Registered** - Hook checks for support but SW may fail to load -5. **PushManager Not Available** - Some browsers block push in non-HTTPS contexts -6. **Database Connection** - Store must be initialized before creating subscription routes - ---- - -## 7. Recommendations for Your Project - -1. **Add clear permission prompt** - Don't auto-prompt on load, wait for user action -2. **Include icons** - Notification needs 192x192+ icon and optional badge -3. **Handle location** - Use `tag` to group notifications -4. **Test on real device** - Most browsers require HTTPS and device support for push -5. **Fallback to in-app** - Always try SSE/Socket.IO first, use push as fallback -6. **Clear error messages** - Show why push failed (permission, support, etc.) -7. **Debounce events** - Prevent notification spam with 500ms-5s delays - ---- - -## Summary - -This implementation uses a robust, production-ready approach combining: - -- **VAPID Authentication** for secure push delivery -- **Workbox Service Worker** for caching and notification handling -- **Hybrid Delivery** (SSE first, push fallback) for optimal UX -- **Debouncing & Cooldown** to prevent spam -- **Database Persistence** for subscription management -- **Deep Linking** for seamless user experience - -The architecture is modular and extensible, making it easy to add more notification channels (Telegram, Slack, etc.) without changing core logic.