mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 08:02:52 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-status-click-focus-pane
# Conflicts: # src/renderer/src/components/settings/ManageSessionsSection.tsx # src/renderer/src/components/status-bar/SessionsStatusSegment.tsx
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
# Resource Usage popover: merge Resources + Sessions into one view
|
||||
|
||||
## Problem
|
||||
|
||||
The status-bar Resource Usage popover currently has two tabs:
|
||||
|
||||
- **Resources** — per-worktree CPU/memory grouped by repo, sourced from
|
||||
`MemorySnapshot.worktrees`. Local PTYs only (SSH excluded by design at
|
||||
`src/main/ipc/pty.ts:832` because remote process trees are not visible
|
||||
to the local `ps`/`wmic` sweep).
|
||||
- **Sessions** — flat list of every PTY the daemon tracks (local **and**
|
||||
SSH), sourced from `pty.listSessions()`. Adds tab-binding info, click-
|
||||
to-navigate, kill-X per row, and "Kill N orphans".
|
||||
|
||||
The data sources only partially overlap: Sessions can show worktrees
|
||||
(e.g. `orca/Porpoise`, `orca/Stingray`) that never appear in Resources
|
||||
because they're SSH-backed. Users see this as a confusing inconsistency
|
||||
between two adjacent tabs in the same popover.
|
||||
|
||||
The user wants one unified view that:
|
||||
|
||||
1. Lists every PTY the daemon knows about (the union, not the
|
||||
intersection).
|
||||
2. Shows CPU/memory **when available** (i.e. for local PTYs) and `—` for
|
||||
remote ones, rather than silently dropping remote sessions.
|
||||
3. Keeps tab-binding affordances (click row → navigate, kill X with
|
||||
confirm dialog) on every row.
|
||||
4. Surfaces orphans (PTYs not bound to any tab) with the "Kill N
|
||||
orphans" bulk action.
|
||||
|
||||
## Direction (already decided by user)
|
||||
|
||||
> Merge into one tab, drop the switcher entirely.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Adding new IPC. The merge uses **only** the data already available:
|
||||
`MemorySnapshot` and `pty.listSessions()`.
|
||||
- Changing what the memory collector tracks. SSH PTYs remain unsampled.
|
||||
The unified view simply shows them with empty metric cells instead of
|
||||
hiding them.
|
||||
- Adding new actions (e.g. "stop all in repo", per-repo restart, etc.).
|
||||
The exact action set today (kill one with confirm, kill orphans, sleep
|
||||
worktree, restart daemon, kill-all-sessions) is preserved.
|
||||
|
||||
## Data model
|
||||
|
||||
### Inputs
|
||||
|
||||
- `snapshot: MemorySnapshot | null` — local-only CPU/Mem data, grouped
|
||||
by `worktreeId`. May be `null` while the daemon is unreachable.
|
||||
- `sessions: DaemonSession[]` — every PTY id the daemon knows, with
|
||||
`cwd`, `title`, and a sticky `isAlive`-equivalent.
|
||||
- `tabsByWorktree`, `ptyIdsByTabId`, `runtimePaneTitlesByTabId`,
|
||||
`workspaceSessionReady` — store-side context used to (a) compute
|
||||
bound/orphan and (b) resolve human labels.
|
||||
|
||||
### Output: a renderer-local view model
|
||||
|
||||
The merge produces a renderer-local view-model — **not** a widening of
|
||||
the shared `WorktreeMemory` shape. `src/shared/types.ts` and the
|
||||
collector are untouched. New types live next to the merge helper:
|
||||
|
||||
```ts
|
||||
// src/renderer/src/components/status-bar/unifiedRow.ts
|
||||
type Metric = number | null // null === "no local sample" (e.g. SSH)
|
||||
|
||||
type UnifiedSessionRow = {
|
||||
sessionId: string
|
||||
label: string
|
||||
bound: boolean
|
||||
tabId: string | null
|
||||
cpu: Metric
|
||||
memory: Metric
|
||||
hasLocalSamples: boolean
|
||||
}
|
||||
|
||||
type UnifiedWorktreeRow = {
|
||||
worktreeId: string
|
||||
worktreeName: string
|
||||
repoId: string
|
||||
repoName: string
|
||||
cpu: Metric
|
||||
memory: Metric
|
||||
history: number[] // empty when hasLocalSamples is false
|
||||
hasLocalSamples: boolean
|
||||
sessions: UnifiedSessionRow[]
|
||||
}
|
||||
|
||||
type UnifiedRepoGroup = {
|
||||
repoId: string
|
||||
repoName: string
|
||||
cpu: Metric // null if every child has hasLocalSamples false
|
||||
memory: Metric
|
||||
hasRemoteChildren: boolean // drives the "· remote" badge on the row
|
||||
worktrees: UnifiedWorktreeRow[]
|
||||
}
|
||||
```
|
||||
|
||||
A small adapter inside `mergeSnapshotAndSessions` converts each
|
||||
`WorktreeMemory` from the shared type into a `UnifiedWorktreeRow` with
|
||||
numeric metrics and `hasLocalSamples: true`. Synthetic remote rows are
|
||||
constructed directly with `null` metrics. The existing `MetricPair` and
|
||||
`Sparkline` callsites in the popover get a thin wrapper that renders
|
||||
`—` when its input is `null`; nothing in `src/shared/` or `src/main/`
|
||||
changes.
|
||||
|
||||
### Merge algorithm
|
||||
|
||||
The merge is renderer-only and pure. Inputs in priority order:
|
||||
|
||||
- **Snapshot first.** `snapshot.worktrees` is the authoritative source
|
||||
for any local PTY's CPU/Mem and for the worktree's identity (it
|
||||
already carries `worktreeId`, `worktreeName`, `repoId`, `repoName`,
|
||||
and a `sessions[]` array with per-session metrics).
|
||||
- **Sessions augments.** `sessions: DaemonSession[]` is the union over
|
||||
the snapshot — it adds (a) SSH PTYs that the local memory collector
|
||||
cannot see and (b) any local PTY that registered too late to make
|
||||
this snapshot's sweep.
|
||||
|
||||
#### Join key (snapshot session ↔ daemon session)
|
||||
|
||||
Both layers identify a session by the **same string**: the PTY id.
|
||||
|
||||
- `SessionMemory.sessionId` (collector) is `pty.sessionId ?? pty.ptyId`
|
||||
from `pty-registry.ts`. In-process local PTYs spawn with
|
||||
`args.sessionId === undefined` (`pty.ts:684`), so the registry stores
|
||||
`sessionId: null` and the fallback `pty.ptyId` is used — which equals
|
||||
`result.id` from the local provider's spawn, which is also what
|
||||
`LocalPtyProvider.listProcesses()` returns as `id`
|
||||
(`local-pty-provider.ts:518-522`).
|
||||
- For daemon-hosted spawns, `mintPtySessionId(worktreeId)` produces the
|
||||
`${worktreeId}@@${shortUuid}` form
|
||||
(`src/main/daemon/pty-session-id.ts`); both the registry and
|
||||
`pty.listSessions()` carry that exact string as the id.
|
||||
|
||||
Therefore the dedup key is simply `session.id` on both sides. The
|
||||
merge MUST build `Set<string>` of all `SessionMemory.sessionId`s from
|
||||
the snapshot and skip any `DaemonSession` whose `id` is already in the
|
||||
set (so a local session never appears twice — once with metrics, once
|
||||
as a `—` placeholder).
|
||||
|
||||
#### Worktree resolution for `DaemonSession`s
|
||||
|
||||
When a `DaemonSession` is **not** already accounted for by the
|
||||
snapshot, we need to bucket it under a worktree group. Try in order:
|
||||
|
||||
1. **Tab-store walk** (existing logic in `SessionsTabPanel`): look up
|
||||
`ptyIdsByTabId` → `tabId` → `tabsByWorktree` → `worktreeId`. This
|
||||
resolves any session bound to a live tab in *this* renderer.
|
||||
2. **Session-id parse**: if the id contains `@@`, take
|
||||
`id.slice(0, id.lastIndexOf('@@'))` as a candidate `worktreeId`.
|
||||
This is the convention enforced by `mintPtySessionId` (see
|
||||
`pty-session-id.ts:5-7` and the symmetric parser in
|
||||
`daemon-pty-adapter.ts:328-331`). It correctly recovers the
|
||||
worktreeId for SSH sessions that haven't been bound to a tab in
|
||||
this Orca instance — which is the user's primary scenario
|
||||
(`orca/Stingray`, `orca/Sawfish`, etc.).
|
||||
3. **Unattributed**: if neither resolves, bucket under a synthetic
|
||||
`unattributed` repo group at the bottom of the list.
|
||||
|
||||
The `repoId` for each new worktree group is recovered the same way the
|
||||
collector does it (`collector.ts:resolveWorktreeNames`): split the
|
||||
worktreeId on the first `::`. Resolved repo display name comes from
|
||||
the renderer-side store (`store.repos[repoId]?.displayName`) when
|
||||
available; otherwise the bare `repoId` is used.
|
||||
|
||||
#### Step-by-step
|
||||
|
||||
1. Initialize `repos: Map<repoId, UnifiedRepoGroup>` empty.
|
||||
2. Insert all `snapshot.worktrees` (if any) into the map, grouped by
|
||||
their `repoId`. Each worktree carries `hasLocalSamples: true` and
|
||||
numeric metrics; its sessions inherit numeric metrics.
|
||||
3. Build `seenSessionIds = new Set<string>(...all session ids in
|
||||
snapshot.worktrees[].sessions)`.
|
||||
4. For each `DaemonSession` in `sessions: DaemonSession[]`:
|
||||
- If `seenSessionIds.has(session.id)`, skip (already merged from
|
||||
snapshot).
|
||||
- Resolve `worktreeId` per the three-step rule above.
|
||||
- If the resolved `worktreeId` is already a worktree group in the
|
||||
map, append the session to its `sessions[]` with `cpu: null`,
|
||||
`memory: null`, `hasLocalSamples: false`. (Reachable for SSH
|
||||
sessions whose worktree happens to match a locally-active
|
||||
worktree id — rare but possible.)
|
||||
- Otherwise, create a new worktree group with `cpu: null`,
|
||||
`memory: null`, `history: []`, `hasLocalSamples: false`, and
|
||||
append the session.
|
||||
5. Compute per-repo aggregates: sum `cpu`/`memory` from worktrees with
|
||||
`hasLocalSamples === true`. If the repo has *any* worktree with
|
||||
`hasLocalSamples === false`, set `repoHasRemoteChildren: true` (used
|
||||
by the UI to render a `· remote` badge on the repo header so the
|
||||
user knows the displayed totals exclude remote worktrees).
|
||||
6. The Orca app section (Main / Renderer / Other) renders unchanged
|
||||
below the list.
|
||||
|
||||
The whole pass is O(W + S) where W = snapshot worktrees and S =
|
||||
sessions. No nested scans.
|
||||
|
||||
### Why this is safe with current data
|
||||
|
||||
- Every existing local session continues to render with the same
|
||||
numbers. The merge **adds** rows; it does not change values.
|
||||
- The snapshot rebuilds every poll (10s), so SSH sessions transitioning
|
||||
to local (or vice versa) flip representation on the next tick. No
|
||||
reconciliation logic needed.
|
||||
- Orphan detection still works: a session is "bound" iff its
|
||||
`sessionId` appears in `boundPtyIds`. Sessions in repos we couldn't
|
||||
resolve still get the X-kill affordance.
|
||||
|
||||
## UI
|
||||
|
||||
### Trigger (status-bar badge) — unchanged
|
||||
|
||||
Mac/Linux/Windows safe (no platform-specific glyphs introduced).
|
||||
|
||||
### Popover — single panel, fixed height
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Resource Usage ⟳ 🗑 ✕ │ ← header
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1.9% · 955.2 MB · 3% of system RAM │ ← summary
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Name CPU Memory · │ ← sort row
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ▾ Triton 0.0% 125.5 MB │
|
||||
│ Terminal 1 0.0% 63.2 MB ✕│
|
||||
│ Terminal 1 0.0% 1.3 MB ✕│
|
||||
│ ▾ Stingray ⓘ remote — — │
|
||||
│ orca/Stingray — — ✕│
|
||||
│ ▾ Sawfish ⓘ remote — — │
|
||||
│ orca/Sawfish — — ✕│
|
||||
│ ───────────────────────── │
|
||||
│ ▸ Orca app 0.5% 829.7 MB │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↑ fixed 420px body, owns its own scroll
|
||||
↓ orphan bulk-kill pill renders here when N>0
|
||||
```
|
||||
|
||||
The header keeps the icon-only **Restart daemon** (RotateCw) and
|
||||
**Kill all sessions** (Trash2) buttons in the top-right.
|
||||
|
||||
### Interaction states (4 paths)
|
||||
|
||||
| Scenario | Resource cells | Kill X | Click row |
|
||||
|-----------------------------|----------------|-------------|--------------|
|
||||
| Local PTY, bound | numeric | hover only | navigates |
|
||||
| Local PTY, orphan | numeric | always | no-op |
|
||||
| Remote PTY, bound | `—` | hover only | navigates |
|
||||
| Remote PTY, orphan | `—` | always | no-op |
|
||||
|
||||
`—` is rendered as `text-muted-foreground/50`. The worktree row carries
|
||||
a small `· remote` badge when `hasLocalSamples === false`; the repo row
|
||||
carries the same badge when `hasRemoteChildren === true` (so users
|
||||
know the displayed repo totals exclude remote worktrees).
|
||||
|
||||
**`bound` semantics are unchanged from today:** a session is bound iff
|
||||
`boundPtyIds.has(session.id)` evaluated against this renderer's
|
||||
`ptyIdsByTabId`. SSH sessions bound to tabs in *another* Orca process
|
||||
look identical to local orphans here — same as before the merge.
|
||||
|
||||
### Empty / loading / error states
|
||||
|
||||
- Daemon unreachable → existing banner remains, body shows nothing.
|
||||
- Sessions error AND no snapshot AND zero sessions → "Resource data
|
||||
unavailable. Restart daemon."
|
||||
- Snapshot present but zero worktrees AND zero sessions → "Nothing
|
||||
running right now."
|
||||
- Loading (no snapshot, no error) → "Loading…".
|
||||
|
||||
### Sort
|
||||
|
||||
Three buttons in the sort row: **Name**, **CPU**, **Memory**. CPU/Mem
|
||||
sort puts `null` metrics last (stable). Name uses `localeCompare`.
|
||||
Selection is repo-group aware: sorting by CPU sorts repos by aggregate
|
||||
CPU and worktrees within a repo by their CPU.
|
||||
|
||||
### Confirm dialogs (unchanged)
|
||||
|
||||
- Per-row kill X: confirm Dialog with copy "Kill this session? Force-
|
||||
quits `<id>`. Any unsaved work in that pane is lost. This can't be
|
||||
undone."
|
||||
- Header Trash2: existing "Kill all sessions" Dialog from
|
||||
`useDaemonActions`.
|
||||
- Header RotateCw: existing "Restart daemon" Dialog from
|
||||
`useDaemonActions`.
|
||||
- Worktree-level Sleep / Delete: unchanged.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
1. **New helper** `mergeSnapshotAndSessions.ts` (renderer-side, pure
|
||||
function) that takes `(snapshot, sessions, storeContext)` and
|
||||
returns the unified `RepoGroup[]` shape above. Unit-testable.
|
||||
2. **Refactor `ResourceUsageStatusSegment.tsx`**:
|
||||
- Remove the tab switcher (drop `activeTab` state, both
|
||||
`'resources'` / `'sessions'` branches, the inline pill component,
|
||||
and the `Trash2` import for the old switcher).
|
||||
- Replace the body with a single render path that consumes the
|
||||
unified `RepoGroup[]` from the helper.
|
||||
- Keep the existing `WorktreeSection`/`AppSection` patterns — extend
|
||||
them to render `null` metrics as `—` and to render the kill-X on
|
||||
each session row (with bound-vs-orphan visibility rule).
|
||||
- Promote `killConfirm` state and the confirm Dialog from
|
||||
`SessionsTabPanel` to the segment level since there's only one
|
||||
panel now.
|
||||
- Keep `onSessionsChanged` plumbing — call it from kill / kill-
|
||||
orphans handlers to trigger an immediate `refreshSessions()`.
|
||||
- Delete the now-unused `SessionsTabPanel` component.
|
||||
3. **Wire metric formatting** to render `—` when metric is `null`. Use
|
||||
the `null`-vs-`0` distinction already present in the data model;
|
||||
don't conflate them.
|
||||
4. **Migration**: status-bar item id stays `'resource-usage'`. The
|
||||
`migrateStatusBarItems` helper is unchanged. No persistence
|
||||
migration needed for this UI-only change.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: `mergeSnapshotAndSessions` with synthetic inputs covering:
|
||||
- All 4 interaction-state paths (local-bound, local-orphan,
|
||||
remote-bound, remote-orphan).
|
||||
- **Dedup**: a local session that appears in both the snapshot and
|
||||
the daemon list is rendered exactly once with numeric metrics.
|
||||
- **`@@` parse**: an SSH session id `repoX::/path/wtA@@abcd` with no
|
||||
matching tab resolves to repoId `repoX`, worktreeId
|
||||
`repoX::/path/wtA`.
|
||||
- **Tab walk wins over `@@` parse**: a session bound to a tab whose
|
||||
worktreeId differs from the `@@` prefix uses the tab's worktreeId
|
||||
(defensive against id-format drift).
|
||||
- **Repo aggregate excludes remote children**: a repo with one
|
||||
local (125 MB) and one remote (`null`) worktree reports
|
||||
`cpu/memory = 125 MB` and `hasRemoteChildren = true`.
|
||||
- Edge cases: snapshot null, sessions empty, both empty, sessionId
|
||||
without `@@` and no tab match → falls into Unattributed.
|
||||
- Manual:
|
||||
- Open with mixed local + SSH worktrees → both render, SSH rows show
|
||||
`—` and a `· remote` badge on their worktree and repo headers.
|
||||
- Kill a remote session via the X → confirm dialog → optimistic
|
||||
removal so row disappears immediately (does not wait 10s).
|
||||
- Kill all (header trash) → orphans gone, bound sessions remain.
|
||||
- Resize window / scroll the body → fixed 420px holds, no reflow.
|
||||
- Accessibility: tab through rows → kill X focusable; after kill
|
||||
confirm, focus lands back on the popover content root, not on
|
||||
`<body>`. Dialog is keyboard-dismissable except while in-flight.
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
- **Risk**: SSH worktrees the user never opened locally still appear in
|
||||
Resources (potentially noisy if the daemon retains many idle SSH
|
||||
sessions). **Mitigation**: bucketing under a `· remote` badge keeps
|
||||
them visually distinct from local-active groups; sort-by-memory pushes
|
||||
them to the bottom. If noise becomes a real complaint, a future PR
|
||||
can add a "hide remote" toggle.
|
||||
- **Risk**: Some sessions can't be resolved to any worktreeId (e.g.
|
||||
daemon-internal sessions whose ids don't follow the `@@` convention,
|
||||
or sessions whose worktreeId resolves to a repo store doesn't know
|
||||
about). **Mitigation**: synthetic `Unattributed` repo group at the
|
||||
bottom, which already exists in spirit as `ORPHAN_WORKTREE_ID` in the
|
||||
memory collector.
|
||||
- **Risk**: Kill-X on a remote session fires `pty.kill(id)`, which the
|
||||
IPC layer routes through `ptyOwnership` to the right SSH provider.
|
||||
Behavior is identical to the old Sessions tab — no new failure mode.
|
||||
- **Risk: poll-rate skew between snapshot (2s) and sessions (10s).**
|
||||
After killing an SSH session, the daemon-side `sessions` list can
|
||||
retain the dead row for up to 10s while the snapshot has already
|
||||
moved on; conversely a freshly-spawned local PTY shows up in
|
||||
`sessions` first and is rendered as a remote placeholder for up to
|
||||
2s before the next snapshot promotes it.
|
||||
**Mitigation**: per-row kill optimistically removes the session id
|
||||
from the renderer's local `sessions` state immediately after the IPC
|
||||
resolves (in addition to calling `onSessionsChanged()`), so the
|
||||
killed row never lingers on screen. The new-session-flash is
|
||||
bounded by `POLL_MS` and acceptable.
|
||||
- **Risk: focus dropped to `<body>` after kill confirm.** When the
|
||||
killed session disappears on the next refresh, the per-row X that
|
||||
had focus is unmounted. With the confirm Dialog now mounted at the
|
||||
segment level, this regression is more visible.
|
||||
**Mitigation**: after `runKillConfirmed` resolves, focus the
|
||||
popover's content root via a stable ref so keyboard users land back
|
||||
in the list, not on `<body>`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No new IPC.
|
||||
- No collector changes.
|
||||
- No new persisted UI prefs (the per-tab visibility split is gone, not
|
||||
replaced with a per-section toggle).
|
||||
- No remote memory sampling — that's a separate, much bigger project.
|
||||
@@ -0,0 +1,176 @@
|
||||
# Tasks Page Resume State
|
||||
|
||||
## Goal
|
||||
|
||||
When a user leaves the Tasks page and comes back later, Orca should reopen the page in the same working context instead of falling back to a generic GitHub Issues/PRs list.
|
||||
|
||||
The resume behavior should cover:
|
||||
|
||||
- GitHub vs Linear.
|
||||
- GitHub Issues/PRs vs GitHub Project mode.
|
||||
- The selected GitHub Issues/PRs preset or custom search.
|
||||
- The selected GitHub Project and Project view.
|
||||
- The selected repos for GitHub Issues/PRs.
|
||||
- The selected Linear teams.
|
||||
|
||||
This should be lightweight. It should not introduce a second project-selection model or duplicate state that is already persisted elsewhere.
|
||||
|
||||
## Existing Persisted State
|
||||
|
||||
The current settings model already remembers most durable task choices:
|
||||
|
||||
- `settings.defaultTaskSource` remembers GitHub vs Linear.
|
||||
- `settings.defaultRepoSelection` remembers GitHub repo selection for cross-repo Issues/PRs.
|
||||
- `settings.defaultLinearTeamSelection` remembers Linear team selection.
|
||||
- `settings.githubProjects.activeProject` remembers the active GitHub Project.
|
||||
- `settings.githubProjects.lastViewByProject` remembers the last selected Project view per Project.
|
||||
- `settings.defaultTaskViewPreset` remembers the user's default GitHub Issues/PRs preset.
|
||||
|
||||
These should stay in place. They are already wired into the page and, for repo/team/project selection, they represent actual user preferences rather than purely transient page state.
|
||||
|
||||
## Missing State
|
||||
|
||||
The missing piece is the user's current page position inside Tasks:
|
||||
|
||||
- Whether GitHub is showing `Issues/PRs` or `Project`.
|
||||
- Which GitHub Issues/PRs preset is currently active.
|
||||
- The currently applied GitHub Issues/PRs query when the user has typed a custom search.
|
||||
- Which Linear preset is currently active.
|
||||
- The currently applied Linear query when the user has typed a search.
|
||||
|
||||
Linear presets are visible task tabs and drive the fetch path, so they must be restored too.
|
||||
|
||||
## Proposed Shape
|
||||
|
||||
Add a small optional field to `PersistedUIState`:
|
||||
|
||||
```ts
|
||||
export type TaskResumeState = {
|
||||
githubMode?: 'items' | 'project'
|
||||
githubItemsPreset?: TaskViewPresetId | null
|
||||
githubItemsQuery?: string
|
||||
linearPreset?: 'assigned' | 'created' | 'all' | 'completed'
|
||||
linearQuery?: string
|
||||
}
|
||||
```
|
||||
|
||||
Then add:
|
||||
|
||||
```ts
|
||||
taskResumeState?: TaskResumeState
|
||||
```
|
||||
|
||||
to `PersistedUIState`.
|
||||
|
||||
Why `PersistedUIState`: this is page-position UI state, similar to sidebar filters and widths. It is not an app-wide setting and should not be presented as a configurable default.
|
||||
|
||||
Do not add `source` to `TaskResumeState`. `settings.defaultTaskSource` already represents the user's last-used task source today, and duplicating it would create two persisted sources of truth. Keep source changes on the existing settings path.
|
||||
|
||||
Do not reset `githubMode` just because the current source is Linear. Source selection and GitHub sub-mode are independent pieces of context: if the user was in GitHub Project mode, switched to Linear, then later switches back to GitHub, GitHub should still reopen in Project mode.
|
||||
|
||||
## Restore Rules
|
||||
|
||||
On Tasks page mount:
|
||||
|
||||
1. If `taskPageData.taskSource` was passed by a caller, use it. Explicit navigation intent should win.
|
||||
2. Otherwise fall back to `settings.defaultTaskSource`.
|
||||
3. Apply these restore rules only after both settings and persisted UI state have hydrated. The current app loads those asynchronously before setting `persistedUIReady`; a `useState` initializer that reads `settings === null` or an unhydrated `taskResumeState` will capture defaults and miss the restored context.
|
||||
|
||||
The restore should run once per Tasks page mount, after hydration is ready. It should not keep reapplying persisted state over local user interactions while the page remains open.
|
||||
|
||||
For GitHub:
|
||||
|
||||
1. Restore `githubMode` from `taskResumeState.githubMode`.
|
||||
2. If mode is `items`, restore `githubItemsPreset` and `githubItemsQuery`.
|
||||
3. Repo selection continues to come from `settings.defaultRepoSelection`.
|
||||
4. If mode is `project`, use `settings.githubProjects.activeProject` and `settings.githubProjects.lastViewByProject`.
|
||||
5. If Project mode has no active project or view, show the Project picker empty state instead of silently switching to Issues/PRs.
|
||||
|
||||
For Linear:
|
||||
|
||||
1. Restore `linearPreset`.
|
||||
2. Restore `linearQuery`.
|
||||
3. Team selection continues to come from `settings.defaultLinearTeamSelection`.
|
||||
|
||||
Fallback behavior:
|
||||
|
||||
- If resume state is absent, use today's defaults.
|
||||
- If `githubItemsPreset` is non-null, derive the query from the preset unless `githubItemsQuery` disagrees because of a future migration. The preset should be treated as authoritative.
|
||||
- If `githubItemsPreset` is null, use `githubItemsQuery` as a custom search.
|
||||
- If `linearQuery` is non-empty, treat it as custom search and keep the restored `linearPreset` as the preset to return to after the search is cleared. The visible active preset should be suppressed while the search input is non-empty, matching today's UI.
|
||||
|
||||
## Write Rules
|
||||
|
||||
Update `taskResumeState` only when the user changes the active page context:
|
||||
|
||||
- Switching GitHub mode between Issues/PRs and Project.
|
||||
- Clicking a GitHub Issues/PRs preset.
|
||||
- Applying the debounced GitHub Issues/PRs search.
|
||||
- Clearing the GitHub Issues/PRs search.
|
||||
- Clicking a Linear preset.
|
||||
- Applying the debounced Linear search.
|
||||
- Clearing the Linear search.
|
||||
|
||||
Do not persist every raw search keystroke. The current UI applies search after a 300 ms debounce, so the persisted value should follow the debounced applied query, not the raw input. A user who types, waits for results, leaves Tasks, and comes back should see the same applied query.
|
||||
|
||||
Task source, repo selection, Linear team selection, active Project, and active Project view should continue writing through their existing settings paths.
|
||||
|
||||
The resume setter should merge partial updates with the existing resume state so changing one dimension does not erase the others. It should persist via `window.api.ui.set({ taskResumeState })` after updating local Zustand state.
|
||||
|
||||
When writing preset selections, clear stale custom-query ambiguity explicitly:
|
||||
|
||||
- GitHub preset click: write `{ githubItemsPreset: presetId, githubItemsQuery: undefined }` or the canonical preset query. Restore must still use the preset as authoritative.
|
||||
- GitHub custom search apply/debounce: write `{ githubItemsPreset: null, githubItemsQuery: trimmedQuery }`.
|
||||
- GitHub search clear: write `{ githubItemsPreset: null, githubItemsQuery: '' }`.
|
||||
- Linear preset click: write `{ linearPreset: presetId, linearQuery: '' }`.
|
||||
- Linear custom search apply/debounce: write `{ linearQuery: trimmedQuery }` without changing `linearPreset`.
|
||||
- Linear search clear: write `{ linearQuery: '' }` without changing `linearPreset`.
|
||||
|
||||
Do not persist mode changes caused only by hiding Project mode while another source is active. In practice, avoid an effect that changes persisted `githubMode` from `project` to `items` when `taskSource !== 'github'`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Primary files:
|
||||
|
||||
- `src/shared/types.ts`: add `TaskResumeState` and `taskResumeState?: TaskResumeState`.
|
||||
- `src/shared/constants.ts`: no required default beyond leaving the field absent. If a concrete default is preferred, use GitHub Issues/PRs with the existing default preset.
|
||||
- `src/renderer/src/store/slices/ui.ts`: hydrate `taskResumeState` defensively, expose a setter such as `setTaskResumeState`, and use it in `openTaskPage` prefetch selection.
|
||||
- `src/renderer/src/components/TaskPage.tsx`: initialize source from existing settings/page data, and initialize GitHub mode, GitHub item preset/query, Linear preset, and Linear query from the resume state.
|
||||
- `src/renderer/src/App.tsx`: no new work should be needed if the field rides the existing `ui:get` hydration.
|
||||
|
||||
The setter should call `window.api.ui.set({ taskResumeState })` through the same persistence path used by other persisted UI fields, or follow the existing store pattern if there is already a central UI save effect for the relevant slice.
|
||||
|
||||
Hydration must sanitize the nested object because `PersistedUIState` comes from disk. Invalid `githubMode`, invalid GitHub preset ids, invalid Linear preset ids, or non-string queries should fall back field-by-field rather than entering Zustand as untrusted values.
|
||||
|
||||
`TaskPage` should subscribe to `persistedUIReady`, `settings`, and `taskResumeState`, then perform a one-shot local initialization once both settings and UI hydration are available. This avoids the current class of bugs where `useState(settings?.defaultTaskViewPreset ?? 'all')` captures `'all'` before settings arrive.
|
||||
|
||||
`openTaskPage` currently prefetches the settings default GitHub preset. After this change, it should prefetch the query the Tasks page will actually mount with:
|
||||
|
||||
1. If explicit `taskPageData.taskSource` is `linear`, skip GitHub prefetch.
|
||||
2. If explicit `taskPageData.taskSource` is `github`, or the resolved source is GitHub, prefetch only when the resolved GitHub mode is `items`.
|
||||
3. If `githubItemsPreset` is non-null, prefetch that preset query.
|
||||
4. If `githubItemsPreset` is null and `githubItemsQuery` is non-empty, prefetch the custom query.
|
||||
5. Otherwise prefetch `settings.defaultTaskViewPreset`.
|
||||
|
||||
The prefetch should also use the same repo selection the page will use: `taskPageData.preselectedRepoId` when present, otherwise `settings.defaultRepoSelection`, otherwise all eligible repos. Warming only `activeRepoId` is acceptable as an optimization fallback, but it is not equivalent to the mounted cross-repo query.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Do not remember scroll position, selected table row, open dialogs, pagination page, Project search overrides, or cached API results in this pass.
|
||||
|
||||
Those states are more fragile because they depend on live remote data. The first version should only restore the user's broad working context.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Open Tasks, switch to Linear, leave Tasks, return to Tasks: Linear is selected.
|
||||
- Open Tasks, switch to GitHub Project mode, switch to Linear, leave Tasks, return to Tasks, then switch back to GitHub: GitHub is still in Project mode.
|
||||
- Open Tasks, switch to GitHub Project mode, select a Project view, leave Tasks, return to Tasks: Project mode opens on the same Project view.
|
||||
- Open Tasks, choose GitHub Issues/PRs `My PRs`, leave Tasks, return to Tasks: GitHub Issues/PRs opens with `My PRs` active.
|
||||
- Open Tasks, type and apply a custom GitHub search, leave Tasks, return to Tasks: GitHub Issues/PRs opens with that custom query and no preset selected.
|
||||
- Open Tasks, type a GitHub search, wait for the debounced results, leave Tasks, return to Tasks: GitHub Issues/PRs opens with that applied query and no preset selected.
|
||||
- Open Tasks, choose Linear `Completed`, leave Tasks, return to Tasks: Linear opens with `Completed` active.
|
||||
- Open Tasks, type a Linear search, wait for the debounced results, leave Tasks, return to Tasks: Linear opens with that applied query.
|
||||
- Open Tasks before hydration has completed, then wait for hydration: Tasks applies the persisted source/mode/preset/query exactly once and does not overwrite subsequent user changes.
|
||||
- Corrupt `taskResumeState` on disk with invalid modes/preset ids/non-string queries, restart, and open Tasks: invalid fields are ignored field-by-field without breaking the page.
|
||||
- Existing repo selection, Linear team selection, and GitHub Project view persistence keep working unchanged.
|
||||
- Restart the app after each source/mode/preset/custom-query scenario above: the same Tasks context is restored from persisted UI/settings state.
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -16,7 +16,7 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.stably.orca.mobile",
|
||||
"buildNumber": "3",
|
||||
"buildNumber": "4",
|
||||
"infoPlist": {
|
||||
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
|
||||
"NSAppTransportSecurity": {
|
||||
|
||||
+46
-2
@@ -1,9 +1,10 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Stack } from 'expo-router'
|
||||
import { Stack, useRouter } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import * as Linking from 'expo-linking'
|
||||
import { colors } from '../src/theme/mobile-theme'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
|
||||
@@ -26,7 +27,49 @@ Notifications.setNotificationHandler({
|
||||
})
|
||||
})
|
||||
|
||||
// Why: extract the path+payload that follows the orca://pair anchor so we
|
||||
// can route it to the confirm screen. Accept either a hash payload
|
||||
// (`orca://pair#<base64>`, the QR / shared form) or a query param
|
||||
// (`orca://pair?code=<...>`, future-proof for share sheets that strip
|
||||
// fragments).
|
||||
function extractPairCode(url: string): string | null {
|
||||
if (!url.startsWith('orca://pair')) return null
|
||||
const hashIndex = url.indexOf('#')
|
||||
if (hashIndex !== -1) {
|
||||
return url.slice(hashIndex + 1) || null
|
||||
}
|
||||
const queryIndex = url.indexOf('?')
|
||||
if (queryIndex !== -1) {
|
||||
const params = new URLSearchParams(url.slice(queryIndex + 1))
|
||||
return params.get('code')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const router = useRouter()
|
||||
|
||||
// Why: route `orca://pair#<code>` deep links to the confirm screen so
|
||||
// the same pairing flow runs whether the link arrived via QR scan,
|
||||
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
|
||||
// covers cold-start (link tapped while app was closed); the listener
|
||||
// covers warm-start (link tapped while app is in memory).
|
||||
useEffect(() => {
|
||||
function handleUrl(url: string) {
|
||||
const code = extractPairCode(url)
|
||||
if (code) {
|
||||
router.push({ pathname: '/pair-confirm', params: { code } })
|
||||
}
|
||||
}
|
||||
|
||||
void Linking.getInitialURL().then((url) => {
|
||||
if (url) handleUrl(url)
|
||||
})
|
||||
|
||||
const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url))
|
||||
return () => sub.remove()
|
||||
}, [router])
|
||||
|
||||
// Why: hide the native splash only once the navigation Stack has been laid
|
||||
// out — this is the earliest moment the user will see actual app content.
|
||||
// Previously the splash hid when a placeholder View rendered, leaving a
|
||||
@@ -55,6 +98,7 @@ export default function RootLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="notifications" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
|
||||
|
||||
+23
-1
@@ -1,11 +1,25 @@
|
||||
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native'
|
||||
import { View, Text, StyleSheet, Pressable, Linking, Platform } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Globe } from 'lucide-react-native'
|
||||
import Svg, { Path } from 'react-native-svg'
|
||||
import Constants from 'expo-constants'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
// Why: read version + native build identifier from expo-constants at
|
||||
// runtime so the About screen never drifts out of sync with app.json.
|
||||
// nativeBuildVersion is iOS buildNumber on iOS and versionCode on
|
||||
// Android — different concepts, same role (monotonic native build id).
|
||||
function getVersionLabel(): string {
|
||||
const version = Constants.expoConfig?.version ?? '?.?.?'
|
||||
const build =
|
||||
Platform.OS === 'ios'
|
||||
? Constants.expoConfig?.ios?.buildNumber
|
||||
: String(Constants.expoConfig?.android?.versionCode ?? '')
|
||||
return build ? `v${version} (${build})` : `v${version}`
|
||||
}
|
||||
|
||||
function GithubIcon({ size = 16, color = colors.textSecondary }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
@@ -66,6 +80,8 @@ export default function AboutScreen() {
|
||||
<Text style={styles.rowValue}>@orca_build</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={styles.versionText}>{getVersionLabel()}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -141,5 +157,11 @@ const styles = StyleSheet.create({
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
versionText: {
|
||||
marginTop: spacing.lg,
|
||||
textAlign: 'center',
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
Alert
|
||||
} from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
|
||||
import { connect, type RpcClient } from '../../../src/transport/rpc-client'
|
||||
import { loadHosts } from '../../../src/transport/host-store'
|
||||
import type { ConnectionState, RpcSuccess } from '../../../src/transport/types'
|
||||
import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme'
|
||||
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
|
||||
import {
|
||||
type AccountsSnapshot,
|
||||
type ProviderKey,
|
||||
getActiveProviderRateLimits,
|
||||
getInactiveProviderUsage,
|
||||
UsageBar
|
||||
} from '../../../src/components/AccountUsage'
|
||||
|
||||
export default function AccountsScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { hostId } = useLocalSearchParams<{ hostId: string }>()
|
||||
|
||||
const [client, setClient] = useState<RpcClient | null>(null)
|
||||
const [connState, setConnState] = useState<ConnectionState>('connecting')
|
||||
const [hostName, setHostName] = useState<string>('')
|
||||
const [snapshot, setSnapshot] = useState<AccountsSnapshot | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
|
||||
// Why: connect to the host's WebSocket on mount and tear down on
|
||||
// unmount. Mirrors the connection lifecycle used in
|
||||
// /h/[hostId]/index.tsx so reconnect/auth-failed states are handled
|
||||
// identically.
|
||||
useEffect(() => {
|
||||
if (!hostId) return
|
||||
let cancelled = false
|
||||
let rpcClient: RpcClient | null = null
|
||||
|
||||
void (async () => {
|
||||
const hosts = await loadHosts()
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (!host) {
|
||||
if (!cancelled) setError('Host not found')
|
||||
return
|
||||
}
|
||||
if (!cancelled) setHostName(host.name)
|
||||
rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => {
|
||||
if (!cancelled) setConnState(state)
|
||||
})
|
||||
clientRef.current = rpcClient
|
||||
if (!cancelled) setClient(rpcClient)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (rpcClient) rpcClient.close()
|
||||
clientRef.current = null
|
||||
}
|
||||
}, [hostId])
|
||||
|
||||
// Why: subscribe to streaming snapshot updates so usage bars refresh in
|
||||
// place when the desktop's rate-limit poll completes (every 5 min) or
|
||||
// when the user switches accounts. Falls back to a one-shot accounts.list
|
||||
// if the subscription stream errors.
|
||||
useEffect(() => {
|
||||
if (!client || connState !== 'connected') return
|
||||
const unsubscribe = client.subscribe('accounts.subscribe', null, (payload) => {
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
const evt = payload as { type?: string; snapshot?: AccountsSnapshot }
|
||||
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
|
||||
setSnapshot(evt.snapshot)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
return unsubscribe
|
||||
}, [client, connState])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!client) return
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const res = await client.sendRequest('accounts.list')
|
||||
if (res.ok) {
|
||||
setSnapshot((res as RpcSuccess).result as AccountsSnapshot)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(res.error.message)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const selectAccount = useCallback(
|
||||
async (provider: ProviderKey, accountId: string | null) => {
|
||||
if (!client) return
|
||||
setBusyAccountId(accountId ?? `${provider}:default`)
|
||||
const method = provider === 'claude' ? 'accounts.selectClaude' : 'accounts.selectCodex'
|
||||
try {
|
||||
const res = await client.sendRequest(method, { accountId })
|
||||
if (!res.ok) {
|
||||
Alert.alert('Could not switch account', res.error.message)
|
||||
} else {
|
||||
// Why: optimistic refresh — the streaming subscription will also
|
||||
// emit, but a one-shot keeps the UI responsive even if the stream
|
||||
// is temporarily disconnected.
|
||||
await refresh()
|
||||
}
|
||||
} catch (e) {
|
||||
Alert.alert('Could not switch account', e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusyAccountId(null)
|
||||
}
|
||||
},
|
||||
[client, refresh]
|
||||
)
|
||||
|
||||
const renderProviderSection = (provider: ProviderKey, title: string) => {
|
||||
if (!snapshot) return null
|
||||
const state = provider === 'claude' ? snapshot.claude : snapshot.codex
|
||||
const activeUsage = getActiveProviderRateLimits(snapshot, provider)
|
||||
const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Icon size={14} />
|
||||
<Text style={styles.sectionHeading}>{title}</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
{/* System default row */}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => selectAccount(provider, null)}
|
||||
disabled={busyAccountId !== null || connState !== 'connected'}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<Text style={styles.rowTitle}>System default</Text>
|
||||
<Text style={styles.rowSubtitle}>Use the agent's own login</Text>
|
||||
</View>
|
||||
<View style={styles.rowTrailing}>
|
||||
{state.activeAccountId === null ? (
|
||||
<Check size={16} color={colors.accentBlue} />
|
||||
) : busyAccountId === `${provider}:default` ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{state.accounts.map((account) => {
|
||||
const isActive = state.activeAccountId === account.id
|
||||
const inactiveEntry = !isActive
|
||||
? getInactiveProviderUsage(snapshot, provider, account.id)
|
||||
: null
|
||||
const usage = isActive ? activeUsage : (inactiveEntry?.claude ?? null)
|
||||
const isFetching =
|
||||
(isActive && usage?.status === 'fetching') ||
|
||||
(!isActive && inactiveEntry?.isFetching === true)
|
||||
const session = usage?.session
|
||||
const weekly = usage?.weekly
|
||||
return (
|
||||
<View key={account.id}>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => selectAccount(provider, account.id)}
|
||||
disabled={busyAccountId !== null || connState !== 'connected' || isActive}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{account.email}
|
||||
</Text>
|
||||
<View style={styles.usageRow}>
|
||||
<UsageBar
|
||||
label="5h"
|
||||
usedPercent={session?.usedPercent ?? null}
|
||||
unavailable={!session && !isFetching}
|
||||
loading={isFetching && !session}
|
||||
/>
|
||||
<UsageBar
|
||||
label="7d"
|
||||
usedPercent={weekly?.usedPercent ?? null}
|
||||
unavailable={!weekly && !isFetching}
|
||||
loading={isFetching && !weekly}
|
||||
/>
|
||||
</View>
|
||||
{usage?.error ? (
|
||||
<Text style={styles.errorText} numberOfLines={1}>
|
||||
{usage.error}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.rowTrailing}>
|
||||
{isActive ? (
|
||||
<Check size={16} color={colors.accentBlue} />
|
||||
) : busyAccountId === account.id ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<View style={styles.titleWrap}>
|
||||
<Text style={styles.heading}>Accounts</Text>
|
||||
{hostName ? (
|
||||
<Text style={styles.subheading} numberOfLines={1}>
|
||||
{hostName}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={refresh}
|
||||
disabled={!client || refreshing || connState !== 'connected'}
|
||||
>
|
||||
{refreshing ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : (
|
||||
<RefreshCw size={18} color={colors.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.scroll, { paddingBottom: insets.bottom + spacing.xl }]}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
tintColor={colors.textSecondary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{connState !== 'connected' && !snapshot ? (
|
||||
<View style={styles.placeholder}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
<Text style={styles.placeholderText}>Connecting to {hostName || 'host'}…</Text>
|
||||
</View>
|
||||
) : error && !snapshot ? (
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
) : !snapshot ? (
|
||||
<View style={styles.placeholder}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
<Text style={styles.placeholderText}>Loading accounts…</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{renderProviderSection('claude', 'Claude')}
|
||||
{renderProviderSection('codex', 'Codex')}
|
||||
<View style={styles.footerHint}>
|
||||
<User size={14} color={colors.textMuted} />
|
||||
<Text style={styles.footerHintText}>
|
||||
Add or re-authenticate accounts from desktop Settings → Accounts.
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.sm,
|
||||
gap: spacing.sm
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
iconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
titleWrap: {
|
||||
flex: 1
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
subheading: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 1
|
||||
},
|
||||
scroll: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.sm
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
sectionHeading: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.card,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowMain: {
|
||||
flex: 1,
|
||||
gap: 4
|
||||
},
|
||||
// Why: fixed-width trailing slot so the usage bars in `rowMain` keep the
|
||||
// same width whether or not the row is currently selected (otherwise the
|
||||
// checkmark on the active account squeezes the bars narrower than the
|
||||
// inactive rows above/below it).
|
||||
rowTrailing: {
|
||||
width: 24,
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.sm
|
||||
},
|
||||
rowTitle: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
rowSubtitle: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
usageRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
marginTop: 4
|
||||
},
|
||||
errorText: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.statusRed
|
||||
},
|
||||
placeholder: {
|
||||
paddingVertical: spacing.xl * 2,
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
footerHint: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingTop: spacing.sm
|
||||
},
|
||||
footerHintText: {
|
||||
flex: 1,
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 18
|
||||
}
|
||||
})
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
Plus,
|
||||
Moon,
|
||||
Filter,
|
||||
Check
|
||||
Check,
|
||||
UserCircle
|
||||
} from 'lucide-react-native'
|
||||
import { connect, type RpcClient } from '../../../src/transport/rpc-client'
|
||||
import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store'
|
||||
@@ -682,6 +683,17 @@ export default function HostScreen() {
|
||||
|
||||
<View style={styles.toolbarSpacer} />
|
||||
|
||||
<Pressable
|
||||
style={styles.searchToggle}
|
||||
onPress={() => router.push(`/h/${hostId}/accounts`)}
|
||||
disabled={connState !== 'connected'}
|
||||
>
|
||||
<UserCircle
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={styles.newButton}
|
||||
onPress={() => setShowNewWorktree(true)}
|
||||
|
||||
@@ -10,6 +10,7 @@ export default function HostGroupLayout() {
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="[hostId]/index" options={{ title: 'Host' }} />
|
||||
<Stack.Screen name="[hostId]/accounts" options={{ title: 'Accounts' }} />
|
||||
<Stack.Screen name="[hostId]/session/[worktreeId]" options={{ title: 'Terminal' }} />
|
||||
</Stack>
|
||||
)
|
||||
|
||||
+290
-32
@@ -13,6 +13,13 @@ import {
|
||||
Terminal,
|
||||
Plus
|
||||
} from 'lucide-react-native'
|
||||
import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons'
|
||||
import {
|
||||
type AccountsSnapshot,
|
||||
type ProviderKey,
|
||||
getActiveProviderRateLimits,
|
||||
UsageBar
|
||||
} from '../src/components/AccountUsage'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { loadHosts, removeHost, renameHost } from '../src/transport/host-store'
|
||||
import { connect, type RpcClient } from '../src/transport/rpc-client'
|
||||
@@ -24,6 +31,7 @@ import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ActionSheetModal } from '../src/components/ActionSheetModal'
|
||||
import { ConfirmModal } from '../src/components/ConfirmModal'
|
||||
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
|
||||
import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache'
|
||||
import { colors, spacing, radii } from '../src/theme/mobile-theme'
|
||||
|
||||
function endpointLabel(endpoint: string): string {
|
||||
@@ -102,16 +110,24 @@ function fetchWorktreeInfo(
|
||||
) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
const markLoaded = () => {
|
||||
setInfo((prev) => ({
|
||||
...prev,
|
||||
[hostId]: {
|
||||
hostId,
|
||||
totalWorktrees: 0,
|
||||
activeCount: 0,
|
||||
lastActiveWorktree: null
|
||||
// Why: only seed an empty zeroed entry when this host has no prior info
|
||||
// at all (e.g., first ever load before any cache hydration). On a
|
||||
// transient failure for a host that already has cached data, leave the
|
||||
// cached entry alone so the Resume card and host-meta line don't
|
||||
// momentarily flip to "0 worktrees" / disappear during reconnects.
|
||||
const markLoadedIfMissing = () => {
|
||||
setInfo((prev) => {
|
||||
if (prev[hostId]) return prev
|
||||
return {
|
||||
...prev,
|
||||
[hostId]: {
|
||||
hostId,
|
||||
totalWorktrees: 0,
|
||||
activeCount: 0,
|
||||
lastActiveWorktree: null
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
client
|
||||
@@ -135,14 +151,34 @@ function fetchWorktreeInfo(
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
markLoaded()
|
||||
markLoadedIfMissing()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed()) markLoaded()
|
||||
if (!disposed()) markLoadedIfMissing()
|
||||
})
|
||||
}
|
||||
|
||||
function fetchAccountsSnapshot(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setSnapshots: (
|
||||
updater: (prev: Record<string, AccountsSnapshot>) => Record<string, AccountsSnapshot>
|
||||
) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
client
|
||||
.sendRequest('accounts.list')
|
||||
.then((response) => {
|
||||
if (disposed()) return
|
||||
if (response.ok) {
|
||||
const snapshot = response.result as AccountsSnapshot
|
||||
setSnapshots((prev) => ({ ...prev, [hostId]: snapshot }))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// Why: repo names get a stable color derived from hashing, matching the
|
||||
// host detail page's colored dots for visual consistency.
|
||||
const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
|
||||
@@ -164,11 +200,53 @@ export default function HomeScreen() {
|
||||
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null)
|
||||
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
|
||||
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
|
||||
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
|
||||
null
|
||||
)
|
||||
const clientsRef = useRef<Array<{ hostId: string; client: RpcClient }>>([])
|
||||
|
||||
// Why: hydrate the home page from a persisted snapshot on cold-start so
|
||||
// Resume + Account-usage cards paint immediately with last-known data
|
||||
// instead of flashing empty for ~1s while the WebSocket reconnects.
|
||||
// Stream/list responses overwrite this seed in place when they arrive.
|
||||
const hydratedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (hydratedRef.current) return
|
||||
hydratedRef.current = true
|
||||
let cancelled = false
|
||||
void loadHomeSnapshot().then((snap) => {
|
||||
if (cancelled || !snap) return
|
||||
setWorktreeInfo((prev) => (Object.keys(prev).length > 0 ? prev : snap.worktreeInfo))
|
||||
setAccountsByHost((prev) => (Object.keys(prev).length > 0 ? prev : snap.accountsByHost))
|
||||
for (const [hostId, info] of Object.entries(snap.worktreeInfo)) {
|
||||
const wt = info.lastActiveWorktree
|
||||
if (wt) {
|
||||
// Why: also seed the in-memory worktree cache so resumeWorktree's
|
||||
// lastVisited fast-path can find the cached worktree object.
|
||||
setCachedWorktrees(hostId, [wt])
|
||||
}
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: persist the merged snapshot whenever either piece updates so the
|
||||
// next cold-start has fresh seed data. The cache module debounces writes
|
||||
// internally so a flurry of streamed updates doesn't hammer disk.
|
||||
useEffect(() => {
|
||||
if (Object.keys(worktreeInfo).length === 0 && Object.keys(accountsByHost).length === 0) {
|
||||
return
|
||||
}
|
||||
saveHomeSnapshot({
|
||||
worktreeInfo,
|
||||
accountsByHost,
|
||||
savedAt: Date.now()
|
||||
})
|
||||
}, [worktreeInfo, accountsByHost])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let stale = false
|
||||
@@ -185,6 +263,7 @@ export default function HomeScreen() {
|
||||
if (entry.client.getState() === 'connected') {
|
||||
fetchStats(entry.client, setStats, () => stale)
|
||||
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
|
||||
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
@@ -222,25 +301,43 @@ export default function HomeScreen() {
|
||||
}
|
||||
|
||||
let unsubNotif: (() => void) | null = null
|
||||
let unsubAccounts: (() => void) | null = null
|
||||
let statsFetched = false
|
||||
const unsubState = client.onStateChange((state) => {
|
||||
if (state === 'connected') {
|
||||
if (!unsubNotif) {
|
||||
unsubNotif = subscribeToDesktopNotifications(client)
|
||||
}
|
||||
if (!unsubAccounts) {
|
||||
unsubAccounts = client.subscribe('accounts.subscribe', null, (payload) => {
|
||||
if (disposed || !payload || typeof payload !== 'object') return
|
||||
const evt = payload as { type?: string; snapshot?: AccountsSnapshot }
|
||||
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
|
||||
const snap = evt.snapshot
|
||||
setAccountsByHost((prev) => ({ ...prev, [host.id]: snap }))
|
||||
}
|
||||
})
|
||||
}
|
||||
if (!statsFetched) {
|
||||
statsFetched = true
|
||||
fetchStats(client, setStats, () => disposed)
|
||||
fetchWorktreeInfo(client, host.id, setWorktreeInfo, () => disposed)
|
||||
}
|
||||
} else if (unsubNotif) {
|
||||
unsubNotif()
|
||||
unsubNotif = null
|
||||
} else {
|
||||
if (unsubNotif) {
|
||||
unsubNotif()
|
||||
unsubNotif = null
|
||||
}
|
||||
if (unsubAccounts) {
|
||||
unsubAccounts()
|
||||
unsubAccounts = null
|
||||
}
|
||||
}
|
||||
})
|
||||
notifCleanups.push(() => {
|
||||
unsubState()
|
||||
unsubNotif?.()
|
||||
unsubAccounts?.()
|
||||
})
|
||||
|
||||
return [{ hostId: host.id, client }]
|
||||
@@ -259,12 +356,20 @@ export default function HomeScreen() {
|
||||
// Why: prefer the worktree the user last opened on this device so the
|
||||
// "Resume" card reflects their mobile session history, not just the
|
||||
// desktop's most-recently-outputting worktree.
|
||||
// Why: rendering used to be gated on hostStates === 'connected', which
|
||||
// caused the Resume card to vanish for ~1s on every cold-start /
|
||||
// resume-from-background while the WebSocket reconnected, even though we
|
||||
// had perfectly good cached worktree data. Now the card stays visible as
|
||||
// long as we have a cached lastActiveWorktree for any known host; the
|
||||
// tap target is still the same and a fresher snapshot from the live RPC
|
||||
// overwrites the card's contents in place when it lands.
|
||||
const resumeWorktree = useMemo(() => {
|
||||
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
|
||||
if (lastVisited && sortedHosts.some((h) => h.id === lastVisited.hostId)) {
|
||||
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
|
||||
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
|
||||
if (match) return { hostId: lastVisited.hostId, worktree: match }
|
||||
}
|
||||
// Prefer a currently-connected host's data when we have it.
|
||||
for (const host of sortedHosts) {
|
||||
if (hostStates[host.id] !== 'connected') continue
|
||||
const info = worktreeInfo[host.id]
|
||||
@@ -272,6 +377,14 @@ export default function HomeScreen() {
|
||||
return { hostId: host.id, worktree: info.lastActiveWorktree }
|
||||
}
|
||||
}
|
||||
// Fall back to whichever known host has cached data, regardless of
|
||||
// current connection state.
|
||||
for (const host of sortedHosts) {
|
||||
const info = worktreeInfo[host.id]
|
||||
if (info?.lastActiveWorktree) {
|
||||
return { hostId: host.id, worktree: info.lastActiveWorktree }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
|
||||
|
||||
@@ -289,6 +402,23 @@ export default function HomeScreen() {
|
||||
[sortedHosts, hostStates, worktreeInfo]
|
||||
)
|
||||
|
||||
// Why: only show the Account usage section for hosts that have at least
|
||||
// one Claude or Codex account configured. Render whenever cached data
|
||||
// exists, regardless of current connection state, so the cards don't
|
||||
// disappear for ~1s on resume while the WebSocket reconnects. Streamed
|
||||
// updates from the live RPC overwrite the snapshot in place when ready.
|
||||
const accountsHosts = useMemo(() => {
|
||||
const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = []
|
||||
for (const host of sortedHosts) {
|
||||
const snap = accountsByHost[host.id]
|
||||
if (!snap) continue
|
||||
const hasClaude = snap.claude.accounts.length > 0
|
||||
const hasCodex = snap.codex.accounts.length > 0
|
||||
if (hasClaude || hasCodex) items.push({ host, snapshot: snap })
|
||||
}
|
||||
return items
|
||||
}, [sortedHosts, hostStates, accountsByHost])
|
||||
|
||||
async function handleRename(newName: string) {
|
||||
if (!renameTarget) return
|
||||
try {
|
||||
@@ -335,8 +465,8 @@ export default function HomeScreen() {
|
||||
<View style={styles.emptyHero}>
|
||||
<Text style={styles.emptyTitle}>Connect your desktop</Text>
|
||||
<Text style={styles.emptyBody}>
|
||||
Pair with Orca on your computer to monitor worktrees, watch agents work, and manage
|
||||
terminals — all from your phone.
|
||||
Pair with Orca on your computer to check on your agents, jump into any terminal, and
|
||||
drive work from your phone.
|
||||
</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={() => router.push('/pair-scan')}>
|
||||
<QrCode size={17} color={colors.bgBase} />
|
||||
@@ -503,6 +633,85 @@ export default function HomeScreen() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ─── Account usage ─── */}
|
||||
{accountsHosts.length > 0 ? (
|
||||
<>
|
||||
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>
|
||||
Account usage
|
||||
</Text>
|
||||
{accountsHosts.map(({ host, snapshot }) => {
|
||||
const claudeActiveId = snapshot.claude.activeAccountId
|
||||
const claudeActive =
|
||||
snapshot.claude.accounts.find((a) => a.id === claudeActiveId) ?? null
|
||||
const codexActiveId = snapshot.codex.activeAccountId
|
||||
const codexActive =
|
||||
snapshot.codex.accounts.find((a) => a.id === codexActiveId) ?? null
|
||||
const showHostName = accountsHosts.length > 1
|
||||
return (
|
||||
<Pressable
|
||||
key={host.id}
|
||||
style={({ pressed }) => [
|
||||
styles.accountsCard,
|
||||
pressed && styles.hostCardPressed
|
||||
]}
|
||||
onPress={() => router.push(`/h/${host.id}/accounts`)}
|
||||
>
|
||||
{showHostName ? (
|
||||
<Text style={styles.accountsHostLabel} numberOfLines={1}>
|
||||
{host.name}
|
||||
</Text>
|
||||
) : null}
|
||||
{(['claude', 'codex'] as ProviderKey[]).map((provider) => {
|
||||
const active = provider === 'claude' ? claudeActive : codexActive
|
||||
const accounts =
|
||||
provider === 'claude'
|
||||
? snapshot.claude.accounts
|
||||
: snapshot.codex.accounts
|
||||
if (accounts.length === 0) return null
|
||||
const limits = getActiveProviderRateLimits(snapshot, provider)
|
||||
const isFetching =
|
||||
limits?.status === 'fetching' || limits?.status === 'idle'
|
||||
const unavailable =
|
||||
limits == null ||
|
||||
limits.status === 'unavailable' ||
|
||||
limits.status === 'error'
|
||||
return (
|
||||
<View key={provider} style={styles.accountsRow}>
|
||||
<View style={styles.accountsIcon}>
|
||||
{provider === 'claude' ? (
|
||||
<ClaudeIcon size={18} />
|
||||
) : (
|
||||
<OpenAIIcon size={18} color={colors.textPrimary} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.accountsInfo}>
|
||||
<Text style={styles.accountsEmail} numberOfLines={1}>
|
||||
{active?.email ?? 'System default'}
|
||||
</Text>
|
||||
<View style={styles.accountsBars}>
|
||||
<UsageBar
|
||||
label="5h"
|
||||
usedPercent={limits?.session?.usedPercent ?? null}
|
||||
unavailable={unavailable}
|
||||
loading={isFetching && limits?.session == null}
|
||||
/>
|
||||
<UsageBar
|
||||
label="7d"
|
||||
usedPercent={limits?.weekly?.usedPercent ?? null}
|
||||
unavailable={unavailable}
|
||||
loading={isFetching && limits?.weekly == null}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ─── Quick actions ─── */}
|
||||
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
|
||||
<View style={styles.quickActions}>
|
||||
@@ -511,7 +720,7 @@ export default function HomeScreen() {
|
||||
onPress={() => router.push('/pair-scan')}
|
||||
>
|
||||
<View style={styles.quickActionIcon}>
|
||||
<QrCode size={20} color={colors.textSecondary} />
|
||||
<QrCode size={16} color={colors.textSecondary} />
|
||||
</View>
|
||||
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
|
||||
</Pressable>
|
||||
@@ -525,7 +734,7 @@ export default function HomeScreen() {
|
||||
}}
|
||||
>
|
||||
<View style={styles.quickActionIcon}>
|
||||
<Plus size={20} color={colors.textSecondary} />
|
||||
<Plus size={16} color={colors.textSecondary} />
|
||||
</View>
|
||||
<Text style={styles.quickActionLabel}>New Worktree</Text>
|
||||
</Pressable>
|
||||
@@ -667,20 +876,21 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: 10,
|
||||
padding: spacing.md
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
statIcon: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 7,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 10
|
||||
marginBottom: 6
|
||||
},
|
||||
statValue: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 20,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.3
|
||||
},
|
||||
@@ -688,7 +898,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.textMuted,
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
marginTop: 3
|
||||
marginTop: 2
|
||||
},
|
||||
|
||||
/* ─── Section heading ─── */
|
||||
@@ -817,6 +1027,53 @@ const styles = StyleSheet.create({
|
||||
flex: 1
|
||||
},
|
||||
|
||||
/* ─── Account usage ─── */
|
||||
accountsCard: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.card,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
accountsHostLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
fontWeight: '500',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4
|
||||
},
|
||||
accountsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2
|
||||
},
|
||||
accountsIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 9,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
accountsInfo: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2
|
||||
},
|
||||
accountsEmail: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
accountsBars: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
marginTop: 4
|
||||
},
|
||||
|
||||
/* ─── Skeleton ─── */
|
||||
skeletonBlock: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
@@ -836,18 +1093,20 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
quickAction: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.card,
|
||||
padding: spacing.lg,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
alignItems: 'center',
|
||||
gap: 10
|
||||
},
|
||||
quickActionIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 9,
|
||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
@@ -855,8 +1114,7 @@ const styles = StyleSheet.create({
|
||||
quickActionLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center'
|
||||
color: colors.textSecondary
|
||||
},
|
||||
|
||||
/* ─── Empty state ─── */
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { parsePairingCode } from '../src/transport/pairing'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import { saveHost, getNextHostName } from '../src/transport/host-store'
|
||||
import type { PairingOffer, RpcResponse } from '../src/transport/types'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
type Status = 'awaiting-confirm' | 'connecting' | 'error'
|
||||
|
||||
export default function PairConfirmScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const params = useLocalSearchParams<{ code?: string }>()
|
||||
const [offer, setOffer] = useState<PairingOffer | null>(null)
|
||||
const [status, setStatus] = useState<Status>('awaiting-confirm')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.code) {
|
||||
setStatus('error')
|
||||
setErrorMessage('Missing pairing code')
|
||||
return
|
||||
}
|
||||
const parsed = parsePairingCode(params.code)
|
||||
if (!parsed) {
|
||||
setStatus('error')
|
||||
setErrorMessage('Not a valid pairing code')
|
||||
return
|
||||
}
|
||||
setOffer(parsed)
|
||||
}, [params.code])
|
||||
|
||||
async function confirm() {
|
||||
if (!offer) return
|
||||
setStatus('connecting')
|
||||
let client: ReturnType<typeof connect> | null = null
|
||||
|
||||
// Why: split the try/catch around the network call vs the local save
|
||||
// so a Keychain or AsyncStorage failure doesn't masquerade as a
|
||||
// "Cannot connect" error.
|
||||
let response: RpcResponse
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64)
|
||||
response = await client.sendRequest('status.get')
|
||||
client.close()
|
||||
client = null
|
||||
} catch (err) {
|
||||
console.warn('[pair-confirm] connect failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage('Cannot connect — check that your computer is on the same network')
|
||||
client?.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
response.error.code === 'unauthorized'
|
||||
? 'Authentication failed — token may be expired'
|
||||
: `Server error: ${response.error.message}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const hostId = `host-${Date.now()}`
|
||||
const hostName = await getNextHostName()
|
||||
await saveHost({
|
||||
id: hostId,
|
||||
name: hostName,
|
||||
endpoint: offer.endpoint,
|
||||
deviceToken: offer.deviceToken,
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
lastConnected: Date.now()
|
||||
})
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch (err) {
|
||||
console.warn('[pair-confirm] save failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
router.replace('/')
|
||||
}
|
||||
|
||||
const containerPadding = { paddingTop: insets.top + spacing.sm }
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerPadding]}>
|
||||
<Pressable style={styles.backButton} onPress={cancel}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.content}>
|
||||
{offer && status === 'awaiting-confirm' && (
|
||||
<>
|
||||
<Text style={styles.title}>Pair with this desktop?</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
You opened a pairing link from your desktop. Confirm to add it to your hosts.
|
||||
</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={() => void confirm()}>
|
||||
<Text style={styles.primaryButtonText}>Pair</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.secondaryButton} onPress={cancel}>
|
||||
<Text style={styles.secondaryButtonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'connecting' && (
|
||||
<>
|
||||
<ActivityIndicator size="large" color={colors.textSecondary} />
|
||||
<Text style={styles.connectingText}>Connecting…</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={cancel}>
|
||||
<Text style={styles.primaryButtonText}>Back to home</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
// Why: nudges the centered group slightly above the geometric
|
||||
// middle so the eye reads it as visually centered above the home
|
||||
// indicator / nav bar.
|
||||
paddingBottom: spacing.xl * 2
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm,
|
||||
textAlign: 'center'
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
marginBottom: spacing.xl,
|
||||
textAlign: 'center'
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button,
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
secondaryButton: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button,
|
||||
alignItems: 'center'
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
},
|
||||
connectingText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
marginTop: spacing.lg,
|
||||
textAlign: 'center'
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
}
|
||||
})
|
||||
+152
-36
@@ -3,12 +3,13 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-nati
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { decodePairingUrl } from '../src/transport/pairing'
|
||||
import { ChevronLeft, Clipboard as ClipboardIcon } from 'lucide-react-native'
|
||||
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import { saveHost, getNextHostName } from '../src/transport/host-store'
|
||||
import type { PairingOffer } from '../src/transport/types'
|
||||
import type { PairingOffer, RpcResponse } from '../src/transport/types'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
|
||||
function Step({ number, text }: { number: number; text: string }) {
|
||||
return (
|
||||
@@ -27,6 +28,7 @@ export default function PairScanScreen() {
|
||||
const [permission, requestPermission] = useCameraPermissions()
|
||||
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [pasteVisible, setPasteVisible] = useState(false)
|
||||
const processingRef = useRef(false)
|
||||
|
||||
const handleBarCodeScanned = useCallback(
|
||||
@@ -47,32 +49,61 @@ export default function PairScanScreen() {
|
||||
[router]
|
||||
)
|
||||
|
||||
const handlePasteSubmit = useCallback((input: string) => {
|
||||
setPasteVisible(false)
|
||||
if (processingRef.current) return
|
||||
processingRef.current = true
|
||||
|
||||
const offer = parsePairingCode(input)
|
||||
if (!offer) {
|
||||
setStatus('error')
|
||||
setErrorMessage('Not a valid pairing code — copy it from your computer and paste again')
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
void testAndSave(offer)
|
||||
}, [])
|
||||
|
||||
async function testAndSave(offer: PairingOffer) {
|
||||
setStatus('connecting')
|
||||
let client: ReturnType<typeof connect> | null = null
|
||||
|
||||
// Why: split the try/catch around the network call vs the local save
|
||||
// so a Keychain or AsyncStorage failure doesn't masquerade as a
|
||||
// "Cannot connect — same network?" error. Pairing reached the
|
||||
// desktop fine; the failure is local persistence.
|
||||
let response: RpcResponse
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64)
|
||||
const response = await client.sendRequest('status.get')
|
||||
response = await client.sendRequest('status.get')
|
||||
client.close()
|
||||
client = null
|
||||
} catch (err) {
|
||||
console.warn('[pair] connect failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage('Cannot connect — check that your computer is on the same network')
|
||||
processingRef.current = false
|
||||
client?.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.error.code === 'unauthorized') {
|
||||
setStatus('error')
|
||||
setErrorMessage('Authentication failed — token may be expired')
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (response.error.code === 'unauthorized') {
|
||||
setStatus('error')
|
||||
setErrorMessage(`Server error: ${response.error.message}`)
|
||||
setErrorMessage('Authentication failed — token may be expired')
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
setStatus('error')
|
||||
setErrorMessage(`Server error: ${response.error.message}`)
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const hostId = `host-${Date.now()}`
|
||||
const hostName = await getNextHostName()
|
||||
|
||||
await saveHost({
|
||||
id: hostId,
|
||||
name: hostName,
|
||||
@@ -81,14 +112,14 @@ export default function PairScanScreen() {
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
lastConnected: Date.now()
|
||||
})
|
||||
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.warn('[pair] save failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage('Cannot connect — check that your computer is on the same network')
|
||||
setErrorMessage(
|
||||
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
processingRef.current = false
|
||||
} finally {
|
||||
client?.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +129,13 @@ export default function PairScanScreen() {
|
||||
processingRef.current = false
|
||||
}
|
||||
|
||||
const containerPadding = { paddingTop: insets.top + spacing.sm }
|
||||
// Why: bottom inset accounts for Android 3-button nav bars and iOS
|
||||
// home-indicator areas that would otherwise overlap the 'Or paste
|
||||
// pairing code' button at the bottom of the scan screen.
|
||||
const containerPadding = {
|
||||
paddingTop: insets.top + spacing.sm,
|
||||
paddingBottom: insets.bottom + spacing.sm
|
||||
}
|
||||
|
||||
if (!permission) {
|
||||
return (
|
||||
@@ -140,20 +177,37 @@ export default function PairScanScreen() {
|
||||
</View>
|
||||
|
||||
{status === 'scanning' && (
|
||||
<View style={styles.cameraWrap}>
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={handleBarCodeScanned}
|
||||
/>
|
||||
<View style={styles.reticle} pointerEvents="none">
|
||||
<View style={[styles.corner, styles.cornerTL]} />
|
||||
<View style={[styles.corner, styles.cornerTR]} />
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
</View>
|
||||
<>
|
||||
{/* Why: unmount the camera while the paste sheet is open. The
|
||||
user has clearly chosen the paste path; keeping the camera
|
||||
streaming behind a sheet wastes power and looks weird if
|
||||
they cancel the sheet and the QR was scanned silently in
|
||||
the meantime. */}
|
||||
{!pasteVisible && (
|
||||
<View style={styles.cameraWrap}>
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={handleBarCodeScanned}
|
||||
/>
|
||||
<View style={styles.reticle} pointerEvents="none">
|
||||
<View style={[styles.corner, styles.cornerTL]} />
|
||||
<View style={[styles.corner, styles.cornerTR]} />
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{pasteVisible && <View style={styles.cameraPlaceholder} />}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.pasteButton, pressed && styles.pasteButtonPressed]}
|
||||
onPress={() => setPasteVisible(true)}
|
||||
>
|
||||
<ClipboardIcon size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.pasteButtonText}>Or paste pairing code</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'connecting' && (
|
||||
@@ -166,11 +220,34 @@ export default function PairScanScreen() {
|
||||
{status === 'error' && (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={retry}>
|
||||
<Text style={styles.primaryButtonText}>Try Again</Text>
|
||||
</Pressable>
|
||||
<View style={styles.errorActions}>
|
||||
<Pressable style={styles.primaryButton} onPress={retry}>
|
||||
<Text style={styles.primaryButtonText}>Try Again</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.secondaryButton,
|
||||
pressed && styles.pasteButtonPressed
|
||||
]}
|
||||
onPress={() => {
|
||||
retry()
|
||||
setPasteVisible(true)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Paste code instead</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TextInputModal
|
||||
visible={pasteVisible}
|
||||
title="Paste pairing code"
|
||||
message="Copy the code shown under the QR on your computer."
|
||||
placeholder="orca://pair#... or paste the code"
|
||||
onSubmit={handlePasteSubmit}
|
||||
onCancel={() => setPasteVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -221,6 +298,14 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radii.camera,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
// Why: holds the layout slot while the camera is unmounted during
|
||||
// paste, so the bottom action button doesn't snap up to fill the
|
||||
// empty space.
|
||||
cameraPlaceholder: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.camera
|
||||
},
|
||||
camera: {
|
||||
...StyleSheet.absoluteFillObject
|
||||
},
|
||||
@@ -303,5 +388,36 @@ const styles = StyleSheet.create({
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
pasteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
pasteButtonPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
pasteButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
},
|
||||
errorActions: {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
secondaryButton: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"expo-linking": "^55.0.14",
|
||||
"expo-notifications": "^55.0.21",
|
||||
"expo-router": "^55.0.13",
|
||||
"expo-secure-store": "^55.0.13",
|
||||
"expo-splash-screen": "^55.0.19",
|
||||
"expo-status-bar": "^55.0.5",
|
||||
"lucide-react-native": "^1.11.0",
|
||||
|
||||
Generated
+12
@@ -38,6 +38,9 @@ importers:
|
||||
expo-router:
|
||||
specifier: ^55.0.13
|
||||
version: 55.0.13(121105c3b042d5e83ab3c1d3b84ed55f)
|
||||
expo-secure-store:
|
||||
specifier: ^55.0.13
|
||||
version: 55.0.13(expo@55.0.17)
|
||||
expo-splash-screen:
|
||||
specifier: ^55.0.19
|
||||
version: 55.0.19(expo@55.0.17)(typescript@5.9.3)
|
||||
@@ -3234,6 +3237,11 @@ packages:
|
||||
react-server-dom-webpack:
|
||||
optional: true
|
||||
|
||||
expo-secure-store@55.0.13:
|
||||
resolution: {integrity: sha512-I6r0JNO1Fd4o0Gu7Ixiic7s89lqgdUHq17uBH9y1f/AntoyKn71TdtYJH82RgfsBbu5qNVzrwImmvlANyOlITQ==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-server@55.0.8:
|
||||
resolution: {integrity: sha512-AoV5TKuO4biSzrhe/OVLyInfTT0pV9/OOc/g/oVq5vmCjL8SaSYTkES8PLt+67Tm7VqX+Dn0+kSx1nQcjEKaPw==}
|
||||
engines: {node: '>=20.16.0'}
|
||||
@@ -9565,6 +9573,10 @@ snapshots:
|
||||
- expo-font
|
||||
- supports-color
|
||||
|
||||
expo-secure-store@55.0.13(expo@55.0.17):
|
||||
dependencies:
|
||||
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
|
||||
|
||||
expo-server@55.0.8: {}
|
||||
|
||||
expo-splash-screen@55.0.19(expo@55.0.17)(typescript@5.9.3):
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
// Why: persist the data needed to render the home page so cold-start /
|
||||
// resume-from-background paints instantly with the last known good
|
||||
// values, then updates in place when fresh RPC data arrives. Without
|
||||
// this, Resume and Account-usage cards flash empty for ~1s while the
|
||||
// WebSocket reconnects and the first responses come back.
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { AccountsSnapshot } from '../components/AccountUsage'
|
||||
|
||||
const STORAGE_KEY = 'orca:home-snapshot:v1'
|
||||
|
||||
type WorktreeSummary = {
|
||||
worktreeId: string
|
||||
repo: string
|
||||
branch: string
|
||||
displayName: string
|
||||
liveTerminalCount: number
|
||||
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
}
|
||||
|
||||
type HostWorktreeInfo = {
|
||||
hostId: string
|
||||
totalWorktrees: number
|
||||
activeCount: number
|
||||
lastActiveWorktree: WorktreeSummary | null
|
||||
}
|
||||
|
||||
export type HomeSnapshot = {
|
||||
worktreeInfo: Record<string, HostWorktreeInfo>
|
||||
accountsByHost: Record<string, AccountsSnapshot>
|
||||
savedAt: number
|
||||
}
|
||||
|
||||
let memoryCache: HomeSnapshot | null = null
|
||||
let writeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
export async function loadHomeSnapshot(): Promise<HomeSnapshot | null> {
|
||||
if (memoryCache) return memoryCache
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as HomeSnapshot
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
typeof parsed.worktreeInfo !== 'object' ||
|
||||
typeof parsed.accountsByHost !== 'object'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
memoryCache = parsed
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: throttle writes so a flurry of streamed account-snapshot updates
|
||||
// (one per provider fetch finishing) doesn't hammer AsyncStorage.
|
||||
export function saveHomeSnapshot(snapshot: HomeSnapshot): void {
|
||||
memoryCache = snapshot
|
||||
if (writeTimer) clearTimeout(writeTimer)
|
||||
writeTimer = setTimeout(() => {
|
||||
writeTimer = null
|
||||
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)).catch(() => {})
|
||||
}, 250)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
// Why: keep these shapes in lockstep with src/shared/types.ts and
|
||||
// src/shared/rate-limit-types.ts. We don't import from desktop here because
|
||||
// the mobile bundle must not pull in Electron-coupled type files.
|
||||
export type RateLimitWindow = {
|
||||
usedPercent: number
|
||||
windowMinutes: number
|
||||
resetsAt: number | null
|
||||
resetDescription: string | null
|
||||
}
|
||||
|
||||
export type ProviderRateLimits = {
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go'
|
||||
session: RateLimitWindow | null
|
||||
weekly: RateLimitWindow | null
|
||||
monthly?: RateLimitWindow | null
|
||||
updatedAt: number
|
||||
error: string | null
|
||||
status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable'
|
||||
}
|
||||
|
||||
export type InactiveAccountUsage = {
|
||||
accountId: string
|
||||
claude: ProviderRateLimits | null
|
||||
updatedAt: number
|
||||
isFetching: boolean
|
||||
}
|
||||
|
||||
export type ClaudeAccountSummary = {
|
||||
id: string
|
||||
email: string
|
||||
organizationName?: string | null
|
||||
}
|
||||
|
||||
export type CodexAccountSummary = {
|
||||
id: string
|
||||
email: string
|
||||
workspaceLabel?: string | null
|
||||
}
|
||||
|
||||
export type AccountsSnapshot = {
|
||||
claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null }
|
||||
codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null }
|
||||
rateLimits: {
|
||||
claude: ProviderRateLimits | null
|
||||
codex: ProviderRateLimits | null
|
||||
inactiveClaudeAccounts: InactiveAccountUsage[]
|
||||
inactiveCodexAccounts: InactiveAccountUsage[]
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderKey = 'claude' | 'codex'
|
||||
|
||||
export function getActiveProviderRateLimits(
|
||||
snapshot: AccountsSnapshot,
|
||||
provider: ProviderKey
|
||||
): ProviderRateLimits | null {
|
||||
return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex
|
||||
}
|
||||
|
||||
export function getInactiveProviderUsage(
|
||||
snapshot: AccountsSnapshot,
|
||||
provider: ProviderKey,
|
||||
accountId: string
|
||||
): InactiveAccountUsage | null {
|
||||
const list =
|
||||
provider === 'claude'
|
||||
? snapshot.rateLimits.inactiveClaudeAccounts
|
||||
: snapshot.rateLimits.inactiveCodexAccounts
|
||||
return list.find((u) => u.accountId === accountId) ?? null
|
||||
}
|
||||
|
||||
// Why: matches desktop StatusBar convention — bars show percent remaining
|
||||
// (so a fresh account renders full, a depleted one renders empty), not
|
||||
// percent used. Color thresholds invert accordingly.
|
||||
export function UsageBar({
|
||||
label,
|
||||
usedPercent,
|
||||
unavailable,
|
||||
loading
|
||||
}: {
|
||||
label: string
|
||||
usedPercent: number | null
|
||||
unavailable: boolean
|
||||
loading?: boolean
|
||||
}) {
|
||||
const remaining = usedPercent == null ? null : Math.max(0, Math.min(100, 100 - usedPercent))
|
||||
const barColor =
|
||||
remaining == null
|
||||
? colors.textMuted
|
||||
: remaining <= 10
|
||||
? colors.statusRed
|
||||
: remaining <= 30
|
||||
? colors.statusAmber
|
||||
: colors.statusGreen
|
||||
return (
|
||||
<View style={styles.usageBar}>
|
||||
<Text style={styles.usageLabel}>{label}</Text>
|
||||
<View style={styles.usageTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.usageFill,
|
||||
{
|
||||
width: `${remaining ?? 0}%`,
|
||||
backgroundColor: unavailable ? colors.textMuted : barColor
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} style={styles.usageSpinner} />
|
||||
) : (
|
||||
<Text style={styles.usageValue}>
|
||||
{unavailable || remaining == null ? '—' : `${Math.round(remaining)}%`}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
usageBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
flex: 1
|
||||
},
|
||||
usageLabel: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
width: 22
|
||||
},
|
||||
usageTrack: {
|
||||
flex: 1,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: colors.bgRaised,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
usageFill: {
|
||||
height: '100%',
|
||||
borderRadius: 3
|
||||
},
|
||||
usageValue: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary,
|
||||
width: 36,
|
||||
textAlign: 'right'
|
||||
},
|
||||
usageSpinner: {
|
||||
width: 36
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import Svg, { Path } from 'react-native-svg'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
// Why: SVG paths sourced from the desktop codebase
|
||||
// (src/renderer/src/components/status-bar/icons.tsx) so mobile and desktop
|
||||
// stay visually identical for Claude/Codex branding.
|
||||
|
||||
export function ClaudeIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
|
||||
fill="#D97757"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpenAIIcon({ size = 16, color }: { size?: number; color?: string }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"
|
||||
fill={color ?? colors.textPrimary}
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Animated, Easing, StyleSheet, View } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
|
||||
// Why: colors and sizing are 1:1 with the desktop StatusIndicator
|
||||
// (src/renderer/src/components/sidebar/StatusIndicator.tsx) so the mobile
|
||||
// worktree list reads identically to the sidebar — same yellow spinner for
|
||||
// 'working', same emerald dot for 'active'/'done', same neutral-500 @ 40%
|
||||
// for 'inactive', same red for 'permission'. Diverging palettes here lose
|
||||
// the design intent ('moving' vs 'alive' vs 'completed') the desktop encodes.
|
||||
const STATUS_COLORS: Record<WorktreeStatus, string> = {
|
||||
working: colors.statusGreen,
|
||||
active: colors.statusGreen,
|
||||
permission: colors.statusRed,
|
||||
done: '#38bdf8',
|
||||
inactive: '#666666'
|
||||
working: '#eab308',
|
||||
active: '#10b981',
|
||||
done: '#10b981',
|
||||
permission: '#ef4444',
|
||||
inactive: 'rgba(115,115,115,0.4)'
|
||||
}
|
||||
|
||||
export function AgentSpinner({ status }: { status: WorktreeStatus }) {
|
||||
@@ -39,23 +44,39 @@ export function AgentSpinner({ status }: { status: WorktreeStatus }) {
|
||||
outputRange: ['0deg', '360deg']
|
||||
})
|
||||
return (
|
||||
<Animated.View style={[styles.spinner, { borderColor: color, transform: [{ rotate }] }]} />
|
||||
<View style={styles.wrapper}>
|
||||
<Animated.View style={[styles.spinner, { borderColor: color, transform: [{ rotate }] }]} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return <View style={[styles.dot, { backgroundColor: color }]} />
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={[styles.dot, { backgroundColor: color }]} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Why: 12x12 wrapper centered around an 8x8 inner glyph mirrors the
|
||||
// desktop's `inline-flex h-3 w-3 ... items-center justify-center` shell
|
||||
// around `size-2` indicator — keeps row height/baseline alignment stable
|
||||
// across status transitions.
|
||||
wrapper: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4
|
||||
},
|
||||
spinner: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
borderWidth: 2,
|
||||
borderTopColor: 'transparent'
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { ClaudeIcon, OpenAIIcon } from './AgentIcons'
|
||||
import { getSuggestedCreatureName } from './worktree-name-suggestion'
|
||||
|
||||
type Repo = {
|
||||
@@ -91,34 +92,10 @@ const AGENT_COMMANDS: Record<string, string> = {
|
||||
|
||||
// ── Agent icons ─────────────────────────────────────────────────────
|
||||
// SVG paths sourced from the desktop codebase:
|
||||
// Claude & OpenAI: src/renderer/src/components/status-bar/icons.tsx
|
||||
// Claude & OpenAI: shared in ./AgentIcons.tsx
|
||||
// Pi & Aider: src/renderer/src/lib/agent-catalog.tsx
|
||||
// Agents with a faviconDomain use Google's favicon service (same as desktop).
|
||||
|
||||
function ClaudeIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
|
||||
fill="#D97757"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
function OpenAIIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"
|
||||
fill={colors.textPrimary}
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PiIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 800 800">
|
||||
|
||||
@@ -41,23 +41,19 @@ export function TextInputModal({
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.group}>
|
||||
<View style={styles.inputWrap}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmit}
|
||||
selectionColor={colors.accentBlue}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmit}
|
||||
selectionColor={colors.accentBlue}
|
||||
/>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
@@ -97,16 +93,12 @@ const styles = StyleSheet.create({
|
||||
color: colors.textMuted,
|
||||
marginTop: 2
|
||||
},
|
||||
group: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
inputWrap: {
|
||||
padding: spacing.md
|
||||
},
|
||||
// Why: matches NewWorktreeModal's input — bgRaised on the modal
|
||||
// background reads as a tappable surface (brighter than the wrapper)
|
||||
// rather than a recessed pit (darker than the wrapper, which is what
|
||||
// bgBase looked like inside a bgPanel group).
|
||||
input: {
|
||||
backgroundColor: colors.bgBase,
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
@@ -127,7 +119,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radii.button
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: colors.accentBlue,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
@@ -144,7 +136,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '500'
|
||||
},
|
||||
submitText: {
|
||||
color: '#fff',
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
|
||||
@@ -350,12 +350,15 @@ const XTERM_HTML = `<!DOCTYPE html>
|
||||
? containerHeightPx
|
||||
: window.innerHeight;
|
||||
var cols = Math.floor(vpWidth / cellWidth);
|
||||
// Why: subtract 2 rows after dividing. The WebView's reported height
|
||||
// can slightly overstate the usable area (layout timing, subpixel
|
||||
// rounding, safe-area insets). Subtracting 2 guarantees the last
|
||||
// visible row plus the shell prompt (which often wraps on narrow
|
||||
// screens) stays fully above the accessory bar.
|
||||
var rows = Math.max(8, Math.floor(vpHeight / cellHeight) - 2);
|
||||
// Why: the rows we report become the PTY's actual row count after the
|
||||
// server fits to viewport, and xterm renders exactly that many lines
|
||||
// anchored top-left of the WebView. Subtracting rows here would leave
|
||||
// dead xterm-background space at the bottom of the container and make
|
||||
// the last PTY rows visually appear above an "invisible line." Any
|
||||
// safety margin between the prompt and the accessory bar must come
|
||||
// from RN layout (terminalFrame's flex bounds), not from undersizing
|
||||
// the PTY.
|
||||
var rows = Math.max(8, Math.floor(vpHeight / cellHeight));
|
||||
notify({ type: 'measure-result', cols: cols, rows: rows });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,76 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { HostProfileSchema, type HostProfile } from './types'
|
||||
import * as SecureStore from 'expo-secure-store'
|
||||
import {
|
||||
HostProfileSchema,
|
||||
StoredHostProfileSchema,
|
||||
type HostProfile,
|
||||
type StoredHostProfile
|
||||
} from './types'
|
||||
|
||||
const STORAGE_KEY = 'orca:hosts'
|
||||
// Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected.
|
||||
// Use dots as the separator so the key shape stays readable while
|
||||
// satisfying the validator.
|
||||
const TOKEN_KEY_PREFIX = 'orca.host-token.'
|
||||
|
||||
// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off
|
||||
// iCloud Keychain and out of iCloud/iTunes backup restores onto a
|
||||
// different physical device. Reads/writes are silent (no biometric
|
||||
// prompt) since we don't request access control flags.
|
||||
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
|
||||
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
|
||||
}
|
||||
|
||||
function tokenKey(hostId: string): string {
|
||||
return `${TOKEN_KEY_PREFIX}${hostId}`
|
||||
}
|
||||
|
||||
export async function loadHosts(): Promise<HostProfile[]> {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
if (!Array.isArray(parsed)) return []
|
||||
|
||||
const out: HostProfile[] = []
|
||||
for (const item of parsed) {
|
||||
// Why: pre-v0.0.3 records carry the deviceToken in AsyncStorage.
|
||||
// Drop them silently — the three pre-launch users will re-pair on
|
||||
// first run rather than carry a migration shim through the auth
|
||||
// path.
|
||||
if (item && typeof item === 'object' && 'deviceToken' in item) {
|
||||
continue
|
||||
}
|
||||
const stored = StoredHostProfileSchema.safeParse(item)
|
||||
if (!stored.success) continue
|
||||
|
||||
const token = await SecureStore.getItemAsync(tokenKey(stored.data.id), KEYCHAIN_OPTIONS)
|
||||
if (!token) {
|
||||
// Why: orphaned metadata with no matching keychain entry — most
|
||||
// likely a stale record from a development install. Skip it
|
||||
// rather than surface a half-broken host.
|
||||
continue
|
||||
}
|
||||
out.push({ ...stored.data, deviceToken: token })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function loadStoredHosts(): Promise<StoredHostProfile[]> {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.flatMap((item) => {
|
||||
const result = HostProfileSchema.safeParse(item)
|
||||
// Why: same drop-old-records rule as loadHosts; keeps internal
|
||||
// mutators from re-persisting pre-v0.0.3 entries.
|
||||
if (item && typeof item === 'object' && 'deviceToken' in item) return []
|
||||
const result = StoredHostProfileSchema.safeParse(item)
|
||||
return result.success ? [result.data] : []
|
||||
})
|
||||
} catch {
|
||||
@@ -18,25 +78,39 @@ export async function loadHosts(): Promise<HostProfile[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHost(host: HostProfile): Promise<void> {
|
||||
const hosts = await loadHosts()
|
||||
const index = hosts.findIndex((h) => h.id === host.id)
|
||||
if (index >= 0) {
|
||||
hosts[index] = host
|
||||
} else {
|
||||
hosts.push(host)
|
||||
function toStored(host: HostProfile): StoredHostProfile {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name,
|
||||
endpoint: host.endpoint,
|
||||
publicKeyB64: host.publicKeyB64,
|
||||
lastConnected: host.lastConnected
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHost(host: HostProfile): Promise<void> {
|
||||
const validated = HostProfileSchema.parse(host)
|
||||
const hosts = await loadStoredHosts()
|
||||
const stored = toStored(validated)
|
||||
const index = hosts.findIndex((h) => h.id === stored.id)
|
||||
if (index >= 0) {
|
||||
hosts[index] = stored
|
||||
} else {
|
||||
hosts.push(stored)
|
||||
}
|
||||
await SecureStore.setItemAsync(tokenKey(stored.id), validated.deviceToken, KEYCHAIN_OPTIONS)
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
|
||||
}
|
||||
|
||||
export async function removeHost(hostId: string): Promise<void> {
|
||||
const hosts = await loadHosts()
|
||||
const hosts = await loadStoredHosts()
|
||||
const filtered = hosts.filter((h) => h.id !== hostId)
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
|
||||
await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
|
||||
}
|
||||
|
||||
export async function renameHost(hostId: string, newName: string): Promise<void> {
|
||||
const hosts = await loadHosts()
|
||||
const hosts = await loadStoredHosts()
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (host) {
|
||||
host.name = newName
|
||||
@@ -45,7 +119,7 @@ export async function renameHost(hostId: string, newName: string): Promise<void>
|
||||
}
|
||||
|
||||
export async function getNextHostName(): Promise<string> {
|
||||
const hosts = await loadHosts()
|
||||
const hosts = await loadStoredHosts()
|
||||
const existingNumbers = hosts
|
||||
.map((h) => {
|
||||
const match = h.name.match(/^Host (\d+)$/)
|
||||
@@ -57,7 +131,7 @@ export async function getNextHostName(): Promise<string> {
|
||||
}
|
||||
|
||||
export async function updateLastConnected(hostId: string): Promise<void> {
|
||||
const hosts = await loadHosts()
|
||||
const hosts = await loadStoredHosts()
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (host) {
|
||||
host.lastConnected = Date.now()
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
import { PairingOfferSchema, type PairingOffer } from './types'
|
||||
|
||||
// Why: this file mirrors src/shared/pairing.ts (which is covered by CI
|
||||
// vitest) but uses atob/btoa because Metro/Hermes don't ship Node's
|
||||
// Buffer. Keep the parsing semantics in sync — when one changes, update
|
||||
// the other.
|
||||
|
||||
export function decodePairingUrl(url: string): PairingOffer | null {
|
||||
try {
|
||||
const hashIndex = url.indexOf('#')
|
||||
if (!url.startsWith('orca://pair') || hashIndex === -1) return null
|
||||
|
||||
const base64url = url.slice(hashIndex + 1)
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const json = atob(base64)
|
||||
const parsed = JSON.parse(json)
|
||||
return PairingOfferSchema.parse(parsed)
|
||||
return decodePairingBase64(url.slice(hashIndex + 1))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: accept either an `orca://pair#<base64>` URL or the bare base64
|
||||
// string so the paste-pair flow can take whichever the user actually
|
||||
// copied from desktop.
|
||||
export function parsePairingCode(input: string): PairingOffer | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return null
|
||||
try {
|
||||
if (trimmed.startsWith('orca://pair')) {
|
||||
return decodePairingUrl(trimmed)
|
||||
}
|
||||
return decodePairingBase64(trimmed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function decodePairingBase64(base64url: string): PairingOffer {
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const json = atob(base64)
|
||||
return PairingOfferSchema.parse(JSON.parse(json))
|
||||
}
|
||||
|
||||
@@ -60,3 +60,16 @@ export const HostProfileSchema = z.object({
|
||||
publicKeyB64: z.string().min(1),
|
||||
lastConnected: z.number().finite()
|
||||
})
|
||||
|
||||
// Why: persisted host record after the v0.0.3 keychain split. The
|
||||
// deviceToken is held in iOS Keychain via expo-secure-store and joined
|
||||
// in at load time; it must NOT appear in AsyncStorage anymore.
|
||||
export const StoredHostProfileSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
endpoint: z.string().min(1),
|
||||
publicKeyB64: z.string().min(1),
|
||||
lastConnected: z.number().finite()
|
||||
})
|
||||
|
||||
export type StoredHostProfile = z.infer<typeof StoredHostProfileSchema>
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.3.35-rc.3",
|
||||
"version": "1.3.35-rc.7",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -104,7 +104,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"simple-git": "^3.33.0",
|
||||
"simple-git": "^3.36.0",
|
||||
"sonner": "^2.0.7",
|
||||
"ssh2": "^1.17.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
Generated
+19
-5
@@ -206,8 +206,8 @@ importers:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(@types/node@25.5.0)(typescript@5.9.3)
|
||||
simple-git:
|
||||
specifier: ^3.33.0
|
||||
version: 3.33.0
|
||||
specifier: ^3.36.0
|
||||
version: 3.36.0
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -2338,6 +2338,12 @@ packages:
|
||||
'@sec-ant/readable-stream@0.4.1':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
'@simple-git/args-pathspec@1.0.3':
|
||||
resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==}
|
||||
|
||||
'@simple-git/argv-parser@1.1.1':
|
||||
resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==}
|
||||
|
||||
'@sindresorhus/is@4.6.0':
|
||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5797,8 +5803,8 @@ packages:
|
||||
simple-get@4.0.1:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
||||
simple-git@3.33.0:
|
||||
resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==}
|
||||
simple-git@3.36.0:
|
||||
resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==}
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
|
||||
@@ -8314,6 +8320,12 @@ snapshots:
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@simple-git/args-pathspec@1.0.3': {}
|
||||
|
||||
'@simple-git/argv-parser@1.1.1':
|
||||
dependencies:
|
||||
'@simple-git/args-pathspec': 1.0.3
|
||||
|
||||
'@sindresorhus/is@4.6.0': {}
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
@@ -12503,10 +12515,12 @@ snapshots:
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
simple-git@3.33.0:
|
||||
simple-git@3.36.0:
|
||||
dependencies:
|
||||
'@kwsites/file-exists': 1.1.1
|
||||
'@kwsites/promise-deferred': 1.1.1
|
||||
'@simple-git/args-pathspec': 1.0.3
|
||||
'@simple-git/argv-parser': 1.1.1
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
+1
-5
@@ -64,11 +64,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
|
||||
if (['repo', 'worktree', 'terminal'].includes(commandPath[0])) {
|
||||
return false
|
||||
}
|
||||
return ![
|
||||
'tab list',
|
||||
'tab create',
|
||||
'tab current'
|
||||
].includes(joined)
|
||||
return !['tab list', 'tab create', 'tab current'].includes(joined)
|
||||
}
|
||||
|
||||
export function isCommandGroup(commandPath: string[]): boolean {
|
||||
|
||||
@@ -5,13 +5,12 @@ import type {
|
||||
BrowserTabSwitchResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatTabList, formatTabListWithProfiles, formatTabShow, printResult } from '../format'
|
||||
import {
|
||||
formatTabList,
|
||||
formatTabListWithProfiles,
|
||||
formatTabShow,
|
||||
printResult
|
||||
} from '../format'
|
||||
import { getOptionalNonNegativeIntegerFlag, getOptionalStringFlag, getRequiredStringFlag } from '../flags'
|
||||
getOptionalNonNegativeIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRequiredStringFlag
|
||||
} from '../flags'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { getBrowserCommandTarget, getBrowserWorktreeSelector } from '../selectors'
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('removeWorktree', () => {
|
||||
|
||||
it('removes the worktree, prunes stale refs, and deletes its local branch', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain -z': {
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
@@ -97,7 +97,7 @@ HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain -z#2': {
|
||||
'git worktree list --porcelain#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
@@ -121,7 +121,7 @@ branch refs/heads/main
|
||||
|
||||
it('skips branch deletion when another worktree still points at the branch', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain -z': {
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
@@ -135,7 +135,7 @@ HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain -z#2': {
|
||||
'git worktree list --porcelain#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
@@ -154,7 +154,7 @@ branch refs/heads/feature/test
|
||||
expect.arrayContaining([
|
||||
'git worktree remove /repo-feature',
|
||||
'git worktree prune',
|
||||
'git worktree list --porcelain -z'
|
||||
'git worktree list --porcelain'
|
||||
])
|
||||
)
|
||||
expect(calls).not.toContain('git branch -D feature/test')
|
||||
@@ -163,7 +163,7 @@ branch refs/heads/feature/test
|
||||
|
||||
it('deletes the branch after prune removes stale sibling worktree entries', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain -z': {
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
@@ -178,7 +178,7 @@ branch refs/heads/feature/test
|
||||
prunable gitdir file points to non-existent location
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain -z#2': {
|
||||
'git worktree list --porcelain#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
@@ -201,7 +201,7 @@ branch refs/heads/main
|
||||
|
||||
it('passes --force before the worktree path when forced removal is requested', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain -z': {
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
@@ -211,7 +211,7 @@ HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain -z#2': {
|
||||
'git worktree list --porcelain#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
@@ -226,7 +226,7 @@ branch refs/heads/main
|
||||
|
||||
it('matches Windows worktree paths before deleting the branch', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain -z': {
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree C:/repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
@@ -236,7 +236,7 @@ HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain -z#2': {
|
||||
'git worktree list --porcelain#2': {
|
||||
stdout: `worktree C:/repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
@@ -259,7 +259,7 @@ branch refs/heads/main
|
||||
it('keeps removal successful when branch cleanup fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain -z': {
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
@@ -269,7 +269,7 @@ HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain -z#2': {
|
||||
'git worktree list --porcelain#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
@@ -306,16 +306,15 @@ describe('listWorktrees', () => {
|
||||
resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`)
|
||||
})
|
||||
|
||||
it('translates parsed path fields from NUL-delimited porcelain output', async () => {
|
||||
it('translates parsed path fields from line-block porcelain output', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' +
|
||||
'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0sparse\0\0'
|
||||
})
|
||||
translateWslOutputPathsMock.mockImplementation((output: string) => {
|
||||
expect(output).not.toContain('\0')
|
||||
return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\')
|
||||
'worktree /home/me/repo\nHEAD abc123\nbranch refs/heads/main\n\n' +
|
||||
'worktree /home/me/repo-feature\nHEAD def456\nbranch refs/heads/feature/test\nsparse\n\n'
|
||||
})
|
||||
translateWslOutputPathsMock.mockImplementation((output: string) =>
|
||||
output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\')
|
||||
)
|
||||
|
||||
await expect(listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).resolves.toEqual([
|
||||
{
|
||||
@@ -337,27 +336,26 @@ describe('listWorktrees', () => {
|
||||
// Why: the non-sparse main worktree gets an fs probe of its sparse config
|
||||
// file; the linked worktree short-circuits on the parsed `sparse` token and
|
||||
// does not. Only one git subprocess runs regardless of worktree count.
|
||||
expect(getGitCalls()).toEqual(['git worktree list --porcelain -z'])
|
||||
expect(getGitCalls()).toEqual(['git worktree list --porcelain'])
|
||||
expect(statMock).toHaveBeenCalledTimes(1)
|
||||
expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('detects sparse checkout after translating paths when porcelain omits sparse token', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
||||
if (args.join(' ') === 'worktree list --porcelain -z') {
|
||||
if (args.join(' ') === 'worktree list --porcelain') {
|
||||
return {
|
||||
stdout:
|
||||
'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' +
|
||||
'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0\0',
|
||||
'worktree /home/me/repo\nHEAD abc123\nbranch refs/heads/main\n\n' +
|
||||
'worktree /home/me/repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n\n',
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected git call: ${args.join(' ')}`)
|
||||
})
|
||||
translateWslOutputPathsMock.mockImplementation((output: string) => {
|
||||
expect(output).not.toContain('\0')
|
||||
return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\')
|
||||
})
|
||||
translateWslOutputPathsMock.mockImplementation((output: string) =>
|
||||
output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\')
|
||||
)
|
||||
const featureWorktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature'
|
||||
resolveGitDirMock.mockImplementation(async (worktreePath: string) =>
|
||||
worktreePath === featureWorktreePath
|
||||
@@ -394,7 +392,7 @@ describe('listWorktrees', () => {
|
||||
// Why: the detection path must not spawn a git subprocess per worktree —
|
||||
// the perf regression in #1131 came from `git sparse-checkout list` firing
|
||||
// on every poll.
|
||||
expect(getGitCalls()).toEqual(['git worktree list --porcelain -z'])
|
||||
expect(getGitCalls()).toEqual(['git worktree list --porcelain'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -428,7 +426,7 @@ describe('addSparseWorktree', () => {
|
||||
'git sparse-checkout set -- packages/web': {
|
||||
error: new Error('sparse setup failed')
|
||||
},
|
||||
'git worktree list --porcelain -z': {
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
@@ -438,7 +436,7 @@ HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain -z#2': {
|
||||
'git worktree list --porcelain#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
||||
@@ -41,12 +41,10 @@ describe('isTransientGhError', () => {
|
||||
// Why: when GitHub returns Retry-After, the server is telling us how long
|
||||
// to wait. Retrying on our 250ms cadence just earns another 429 and burns
|
||||
// the retry budget.
|
||||
expect(
|
||||
isTransientGhError('HTTP 429 Too Many Requests\nRetry-After: 60\n')
|
||||
).toBe(false)
|
||||
expect(isTransientGhError('HTTP 429 Too Many Requests\nRetry-After: 60\n')).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT retry 4xx that aren\'t 429', () => {
|
||||
it("does NOT retry 4xx that aren't 429", () => {
|
||||
expect(isTransientGhError('HTTP 401 Unauthorized')).toBe(false)
|
||||
expect(isTransientGhError('HTTP 404 Not Found')).toBe(false)
|
||||
expect(isTransientGhError('HTTP 422 Unprocessable Entity')).toBe(false)
|
||||
|
||||
+77
-23
@@ -6,13 +6,7 @@
|
||||
* This module detects WSL paths and routes command execution through `wsl.exe -d <distro>`
|
||||
* with translated Linux paths, so every call site gets WSL support for free.
|
||||
*/
|
||||
import {
|
||||
execFile,
|
||||
execFileSync,
|
||||
spawn,
|
||||
type ChildProcess,
|
||||
type SpawnOptions
|
||||
} from 'child_process'
|
||||
import { execFile, execFileSync, spawn, type ChildProcess, type SpawnOptions } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl'
|
||||
|
||||
@@ -99,9 +93,7 @@ function resolveCommand(
|
||||
// inside the bash -c string. Single quotes are safe for all chars except
|
||||
// single quotes themselves, which we escape as '\'' (end quote, escaped
|
||||
// literal, reopen quote).
|
||||
const escapedArgs = translatedArgs.map(
|
||||
(a) => `'${a.replace(/'/g, "'\\''")}'`
|
||||
)
|
||||
const escapedArgs = translatedArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`)
|
||||
// Why: when cwd is supplied as a WSL UNC path, prepend `cd <linuxPath> &&`
|
||||
// so the command runs in the expected directory. When the caller only
|
||||
// supplied a distro override (no cwd), skip the cd entirely — the gh CLI
|
||||
@@ -193,10 +185,7 @@ export function gitExecFileSync(
|
||||
* Spawn a git child process. Drop-in replacement for
|
||||
* `spawn('git', args, { cwd, stdio, ... })`.
|
||||
*/
|
||||
export function gitSpawn(
|
||||
args: string[],
|
||||
options: SpawnOptions & { cwd: string }
|
||||
): ChildProcess {
|
||||
export function gitSpawn(args: string[], options: SpawnOptions & { cwd: string }): ChildProcess {
|
||||
const resolved = resolveCommand('git', args, options.cwd)
|
||||
return spawn(resolved.binary, resolved.args, {
|
||||
...options,
|
||||
@@ -216,7 +205,69 @@ export function gitSpawn(
|
||||
// resolveCommand routes the spawn through `wsl.exe -d <distro> -- gh ...`
|
||||
// even without a UNC cwd to parse a distro from. Repo-scoped callers should
|
||||
// keep using cwd — the distro derives from the path automatically there.
|
||||
type GhExecOptions = Omit<GitExecOptions, 'cwd'> & { cwd?: string; wslDistro?: string }
|
||||
// Why: `idempotent` gates the transient-error retry. When undefined we
|
||||
// auto-detect from argv (writes are detected by `-X POST/PATCH/PUT/DELETE`
|
||||
// or a `query=mutation …` arg); callers can also pass an explicit override.
|
||||
// A 5xx/socket reset after the request reaches GitHub but before the
|
||||
// response returns is the canonical case where the server-side write
|
||||
// succeeded; retrying would create a duplicate comment/issue/label addition.
|
||||
// See bug-scan finding 1.
|
||||
type GhExecOptions = Omit<GitExecOptions, 'cwd'> & {
|
||||
cwd?: string
|
||||
wslDistro?: string
|
||||
idempotent?: boolean
|
||||
}
|
||||
|
||||
const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE'])
|
||||
// `gh <noun> <verb>` write subcommands. Reads (view/list/status/checks)
|
||||
// are absent on purpose so the default of "retry" stays for them.
|
||||
const NON_IDEMPOTENT_GH_VERBS = new Set([
|
||||
'create',
|
||||
'edit',
|
||||
'delete',
|
||||
'close',
|
||||
'reopen',
|
||||
'merge',
|
||||
'comment',
|
||||
'review',
|
||||
'ready',
|
||||
'lock',
|
||||
'unlock',
|
||||
'pin',
|
||||
'unpin',
|
||||
'transfer',
|
||||
'develop'
|
||||
])
|
||||
|
||||
function argsLookIdempotent(args: string[]): boolean {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i]
|
||||
if (a === '-X' || a === '--method') {
|
||||
const next = args[i + 1]
|
||||
if (typeof next === 'string' && NON_IDEMPOTENT_METHODS.has(next.toUpperCase())) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// `gh api graphql -f query=mutation(...){ ... }` — detect mutation queries
|
||||
// so writes via the GraphQL endpoint also fail fast on transient errors.
|
||||
if (a.startsWith('query=')) {
|
||||
const trimmed = a.slice('query='.length).trimStart().toLowerCase()
|
||||
if (trimmed.startsWith('mutation')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
// `gh issue close`, `gh pr edit`, `gh pr merge`, etc. The first arg is the
|
||||
// noun (issue/pr/repo/label/...) and the second is the verb. Defaulting
|
||||
// `gh api` calls without an explicit -X to GET-equivalent (idempotent) is
|
||||
// intentional: callers that POST through `gh api` set `-X POST`.
|
||||
if (args.length >= 2 && args[0] !== 'api') {
|
||||
if (NON_IDEMPOTENT_GH_VERBS.has(args[1])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract stderr from an execFile rejection.
|
||||
@@ -355,7 +406,14 @@ export async function ghExecFileAsync(
|
||||
lastError = err
|
||||
const { stderr } = extractExecError(err)
|
||||
const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length
|
||||
if (!isLastAttempt && isTransientGhError(stderr)) {
|
||||
// Why: only retry idempotent calls. A 5xx/socket reset can arrive
|
||||
// after the server already applied a POST/PATCH/PUT/DELETE; retrying
|
||||
// would duplicate the write (e.g. double-post a comment, double-add
|
||||
// a label). When the caller doesn't say, we auto-detect from argv —
|
||||
// explicit `-X <method>` and GraphQL `query=mutation …` are treated
|
||||
// as non-idempotent. See bug-scan finding 1.
|
||||
const idempotent = options.idempotent ?? argsLookIdempotent(args)
|
||||
if (idempotent && !isLastAttempt && isTransientGhError(stderr)) {
|
||||
// Why: when the upstream surfaced a Retry-After (e.g. on a transient
|
||||
// 5xx that GitHub explicitly recommends backing off for), honor it
|
||||
// instead of using our default backoff — sleeping less than the
|
||||
@@ -406,10 +464,7 @@ export function wslAwareSpawn(
|
||||
* are Linux-native (/home/user/repo). The rest of Orca needs Windows UNC
|
||||
* paths (\\wsl.localhost\Ubuntu\home\user\repo) to read files via Node fs.
|
||||
*/
|
||||
export function translateWslOutputPaths(
|
||||
output: string,
|
||||
originalCwd: string
|
||||
): string {
|
||||
export function translateWslOutputPaths(output: string, originalCwd: string): string {
|
||||
const wsl = parseWslPath(originalCwd)
|
||||
if (!wsl) {
|
||||
return output
|
||||
@@ -417,9 +472,8 @@ export function translateWslOutputPaths(
|
||||
|
||||
// Replace absolute Linux paths that start with / and look like filesystem
|
||||
// paths in structured git output (e.g. "worktree /home/user/repo/feature")
|
||||
return output.replace(
|
||||
/(?<=worktree )(\/.+)$/gm,
|
||||
(_match, linuxPath: string) => toWindowsWslPath(linuxPath, wsl.distro)
|
||||
return output.replace(/(?<=worktree )(\/.+)$/gm, (_match, linuxPath: string) =>
|
||||
toWindowsWslPath(linuxPath, wsl.distro)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+13
-32
@@ -35,12 +35,10 @@ function looksLikeWindowsPath(pathValue: string): boolean {
|
||||
*/
|
||||
export function parseWorktreeList(output: string): GitWorktreeInfo[] {
|
||||
const worktrees: GitWorktreeInfo[] = []
|
||||
const blocks = output.includes('\0')
|
||||
? parseNullDelimitedWorktreeBlocks(output)
|
||||
: output
|
||||
.trim()
|
||||
.split(/\r?\n\r?\n/)
|
||||
.map((block) => block.trim().split(/\r?\n/))
|
||||
const blocks = output
|
||||
.trim()
|
||||
.split(/\r?\n\r?\n/)
|
||||
.map((block) => block.trim().split(/\r?\n/))
|
||||
|
||||
for (const lines of blocks) {
|
||||
if (lines.length === 0) {
|
||||
@@ -88,11 +86,12 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] {
|
||||
*/
|
||||
export async function listWorktrees(repoPath: string): Promise<GitWorktreeInfo[]> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], {
|
||||
// Why: do not pass `-z` here. `-z` requires Git ≥ 2.36; older Git rejects
|
||||
// it, listWorktrees returns [], and every create flow throws "Worktree
|
||||
// created but not found in listing" (issue #1453).
|
||||
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], {
|
||||
cwd: repoPath
|
||||
})
|
||||
// Why: WSL path translation is line-oriented, but `-z` porcelain output is
|
||||
// NUL-delimited. Parse first so only complete path fields are translated.
|
||||
const worktrees = parseWorktreeList(stdout).map((worktree) => {
|
||||
const translatedPath = translateWorktreePath(worktree.path, repoPath)
|
||||
return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath }
|
||||
@@ -106,7 +105,11 @@ export async function listWorktrees(repoPath: string): Promise<GitWorktreeInfo[]
|
||||
return isSparse ? { ...worktree, isSparse } : worktree
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Why: a silent catch turned issue #1453's underlying
|
||||
// "git: unknown switch -z" into the opaque "not found in listing" toast.
|
||||
// Surface the cause so future regressions show up immediately.
|
||||
console.warn('[git/worktree] listWorktrees failed:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -273,28 +276,6 @@ export async function removeWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
function parseNullDelimitedWorktreeBlocks(output: string): string[][] {
|
||||
const blocks: string[][] = []
|
||||
let current: string[] = []
|
||||
|
||||
for (const token of output.split('\0')) {
|
||||
if (!token) {
|
||||
if (current.length > 0) {
|
||||
blocks.push(current)
|
||||
current = []
|
||||
}
|
||||
continue
|
||||
}
|
||||
current.push(token)
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
blocks.push(current)
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
function translateWorktreePath(worktreePath: string, repoPath: string): string {
|
||||
const prefix = 'worktree '
|
||||
const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath)
|
||||
|
||||
+78
-39
@@ -851,17 +851,21 @@ export async function getWorkItemByOwnerRepo(
|
||||
/**
|
||||
* Get PR info for a given branch using gh CLI.
|
||||
* Returns null if gh is not installed, or no PR exists for the branch.
|
||||
*
|
||||
* When `linkedPRNumber` is provided and the branch lookup yields nothing,
|
||||
* falls back to looking up the PR by number. This handles "create from PR"
|
||||
* worktrees, whose branch is a fresh local branch (not the PR's head ref) —
|
||||
* the branch-keyed lookup misses, but the user still expects the linked PR
|
||||
* to surface on the worktree card.
|
||||
*/
|
||||
export async function getPRForBranch(repoPath: string, branch: string): Promise<PRInfo | null> {
|
||||
export async function getPRForBranch(
|
||||
repoPath: string,
|
||||
branch: string,
|
||||
linkedPRNumber?: number | null
|
||||
): Promise<PRInfo | null> {
|
||||
// Strip refs/heads/ prefix if present
|
||||
const branchName = branch.replace(/^refs\/heads\//, '')
|
||||
|
||||
// During a rebase the worktree is in detached HEAD and branch is empty.
|
||||
// An empty --head filter causes gh to return an arbitrary PR — bail early.
|
||||
if (!branchName) {
|
||||
return null
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const ownerRepo = await getOwnerRepo(repoPath)
|
||||
@@ -880,38 +884,73 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
|
||||
headRefOid?: string
|
||||
} | null = null
|
||||
|
||||
if (ownerRepo) {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
'--head',
|
||||
branchName,
|
||||
'--state',
|
||||
'all',
|
||||
'--limit',
|
||||
'1',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
const list = JSON.parse(stdout) as NonNullable<typeof data>[]
|
||||
data = list[0] ?? null
|
||||
} else {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
branchName,
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
data = JSON.parse(stdout)
|
||||
// During a rebase the worktree is in detached HEAD and branch is empty.
|
||||
// An empty --head filter causes gh to return an arbitrary PR — skip the
|
||||
// branch lookup and rely on the linkedPR fallback below if available.
|
||||
if (branchName) {
|
||||
if (ownerRepo) {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
'--head',
|
||||
branchName,
|
||||
'--state',
|
||||
'all',
|
||||
'--limit',
|
||||
'1',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
const list = JSON.parse(stdout) as NonNullable<typeof data>[]
|
||||
data = list[0] ?? null
|
||||
} else {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
branchName,
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
data = JSON.parse(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
if (!data && typeof linkedPRNumber === 'number') {
|
||||
const args = ownerRepo
|
||||
? [
|
||||
'pr',
|
||||
'view',
|
||||
String(linkedPRNumber),
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
]
|
||||
: [
|
||||
'pr',
|
||||
'view',
|
||||
String(linkedPRNumber),
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
]
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(args, { cwd: repoPath })
|
||||
data = JSON.parse(stdout)
|
||||
} catch {
|
||||
// Why: a stale linkedPRNumber (PR deleted, wrong repo, …) makes
|
||||
// `gh pr view <number>` reject. Treat that as the no-PR case so
|
||||
// callers see the historical `null` semantics instead of a thrown
|
||||
// error every poll cycle.
|
||||
data = null
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
|
||||
@@ -20,9 +20,9 @@ describe('classifyProjectError', () => {
|
||||
})
|
||||
|
||||
it('classifies "Could not resolve to a User" as not_found', () => {
|
||||
expect(
|
||||
classifyProjectError('Could not resolve to a User with the login of foo', '').type
|
||||
).toBe('not_found')
|
||||
expect(classifyProjectError('Could not resolve to a User with the login of foo', '').type).toBe(
|
||||
'not_found'
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies "could not resolve host" as network_error, NOT not_found', () => {
|
||||
@@ -117,9 +117,12 @@ describe('parseProjectPaste', () => {
|
||||
})
|
||||
|
||||
it('parses org URL with view number', () => {
|
||||
expect(
|
||||
parseProjectPaste('https://github.com/orgs/acme/projects/42/views/3')
|
||||
).toEqual({ kind: 'org', owner: 'acme', number: 42, viewNumber: 3 })
|
||||
expect(parseProjectPaste('https://github.com/orgs/acme/projects/42/views/3')).toEqual({
|
||||
kind: 'org',
|
||||
owner: 'acme',
|
||||
number: 42,
|
||||
viewNumber: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('parses user URL', () => {
|
||||
|
||||
Binary file not shown.
@@ -82,9 +82,13 @@ export function validateSlugArgs(
|
||||
repo: unknown
|
||||
): { ok: true } | { ok: false; error: GitHubProjectViewError } {
|
||||
const o = assertSlug(owner, 'owner')
|
||||
if (!o.ok) {return { ok: false, error: o.error }}
|
||||
if (!o.ok) {
|
||||
return { ok: false, error: o.error }
|
||||
}
|
||||
const r = assertSlug(repo, 'repo')
|
||||
if (!r.ok) {return { ok: false, error: r.error }}
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -103,7 +107,9 @@ export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlE
|
||||
// fails, fall back to stderr.
|
||||
const sources = [stdout, stderr]
|
||||
for (const src of sources) {
|
||||
if (!src) {continue}
|
||||
if (!src) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] }
|
||||
if (parsed.errors && parsed.errors.length > 0) {
|
||||
@@ -116,20 +122,23 @@ export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlE
|
||||
return []
|
||||
}
|
||||
|
||||
export function errorsIndicateParentField(
|
||||
errors: GhGraphqlErrorShape[],
|
||||
stderr: string
|
||||
): boolean {
|
||||
export function errorsIndicateParentField(errors: GhGraphqlErrorShape[], stderr: string): boolean {
|
||||
const lower = stderr.toLowerCase()
|
||||
// Preview-header shape: gh returns a 4xx with "preview" in the message.
|
||||
if (lower.includes('preview') && lower.includes('parent')) {return true}
|
||||
if (lower.includes('preview') && lower.includes('parent')) {
|
||||
return true
|
||||
}
|
||||
return errors.some((e) => {
|
||||
const type = (e.type ?? '').toUpperCase()
|
||||
if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') {
|
||||
const tail = e.path?.at(-1)
|
||||
if (tail === 'parent') {return true}
|
||||
if (tail === 'parent') {
|
||||
return true
|
||||
}
|
||||
// FIELD_ERRORS often omits `path`; match on message for the parent field.
|
||||
if ((e.message ?? '').toLowerCase().includes('parent')) {return true}
|
||||
if ((e.message ?? '').toLowerCase().includes('parent')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
@@ -140,7 +149,11 @@ export function classifyProjectError(stderr: string, stdout: string): GitHubProj
|
||||
const s = stderr.toLowerCase()
|
||||
|
||||
// Auth
|
||||
if (s.includes('authentication required') || s.includes('not logged in') || s.includes('gh auth login')) {
|
||||
if (
|
||||
s.includes('authentication required') ||
|
||||
s.includes('not logged in') ||
|
||||
s.includes('gh auth login')
|
||||
) {
|
||||
return {
|
||||
type: 'auth_required',
|
||||
message: 'Sign in to GitHub to load project tasks. Run `gh auth login`.'
|
||||
@@ -149,7 +162,7 @@ export function classifyProjectError(stderr: string, stdout: string): GitHubProj
|
||||
// Scope
|
||||
if (
|
||||
s.includes('missing required scope') ||
|
||||
s.includes("your token has not been granted") ||
|
||||
s.includes('your token has not been granted') ||
|
||||
(s.includes('resource not accessible') && (s.includes('project') || s.includes('scope')))
|
||||
) {
|
||||
return {
|
||||
@@ -181,7 +194,11 @@ export function classifyProjectError(stderr: string, stdout: string): GitHubProj
|
||||
if (
|
||||
s.includes('http 404') ||
|
||||
errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') ||
|
||||
s.includes('could not resolve to a ')
|
||||
// Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to
|
||||
// an Organization") and "to a" otherwise. The previous singular-only
|
||||
// check missed the "an" variants when gh emits only the stderr summary
|
||||
// without a structured GraphQL error array. See bug-scan finding 3.
|
||||
/could not resolve to an? /.test(s)
|
||||
) {
|
||||
const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND')
|
||||
return {
|
||||
@@ -208,12 +225,16 @@ export function classifyProjectError(stderr: string, stdout: string): GitHubProj
|
||||
// Why: don't leak full stderr to the UI — it can include verbose request
|
||||
// dumps with header diagnostics. Truncate to the first non-empty line and
|
||||
// cap length so unexpected diagnostics stay readable but bounded.
|
||||
const firstLine = stderr
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.length > 0) ?? ''
|
||||
const firstLine =
|
||||
stderr
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.length > 0) ?? ''
|
||||
const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine
|
||||
return { type: 'unknown', message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.' }
|
||||
return {
|
||||
type: 'unknown',
|
||||
message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.'
|
||||
}
|
||||
}
|
||||
|
||||
export function driftError(
|
||||
@@ -228,9 +249,11 @@ export function driftError(
|
||||
// same `rate_limited` error shape as the post-hoc classifier so the UI path
|
||||
// is unchanged. We DO NOT fail open here when there's no cached snapshot —
|
||||
// rateLimitGuard already handles that case (returns `blocked:false`).
|
||||
export function rateLimitedError(
|
||||
blocked: { remaining: number; limit: number; resetAt: number }
|
||||
): GitHubProjectViewError {
|
||||
export function rateLimitedError(blocked: {
|
||||
remaining: number
|
||||
limit: number
|
||||
resetAt: number
|
||||
}): GitHubProjectViewError {
|
||||
const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000))
|
||||
const mins = Math.ceil(resetIn / 60)
|
||||
return {
|
||||
|
||||
@@ -127,7 +127,9 @@ export async function updateProjectItemFieldValue(
|
||||
value: valVar.val
|
||||
}
|
||||
const res = await runGraphql<unknown>(query, vars)
|
||||
if (!res.ok) {return { ok: false, error: res.error }}
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -151,7 +153,9 @@ export async function clearProjectItemFieldValue(
|
||||
itemId: args.itemId,
|
||||
fieldId: args.fieldId
|
||||
})
|
||||
if (!res.ok) {return { ok: false, error: res.error }}
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -161,13 +165,18 @@ export async function updateIssueBySlug(
|
||||
args: UpdateIssueBySlugArgs
|
||||
): Promise<GitHubProjectMutationResult> {
|
||||
const v = validateSlugArgs(args.owner, args.repo)
|
||||
if (!v.ok) {return v}
|
||||
if (!v.ok) {
|
||||
return v
|
||||
}
|
||||
const n = assertPositiveInt(args.number, 'number')
|
||||
if (!n.ok) {return { ok: false, error: n.error }}
|
||||
if (!n.ok) {
|
||||
return { ok: false, error: n.error }
|
||||
}
|
||||
if (!args.updates || typeof args.updates !== 'object') {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } }
|
||||
}
|
||||
const { title, body, state, addLabels, removeLabels, addAssignees, removeAssignees } = args.updates
|
||||
const { title, body, state, addLabels, removeLabels, addAssignees, removeAssignees } =
|
||||
args.updates
|
||||
|
||||
// Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}.
|
||||
// Labels/assignees go through their dedicated endpoints.
|
||||
@@ -176,11 +185,19 @@ export async function updateIssueBySlug(
|
||||
// 1) PATCH body
|
||||
if (title !== undefined || body !== undefined || state !== undefined) {
|
||||
const patchArgs: string[] = ['-X', 'PATCH', base]
|
||||
if (title !== undefined) {patchArgs.push('--raw-field', `title=${title}`)}
|
||||
if (body !== undefined) {patchArgs.push('--raw-field', `body=${body}`)}
|
||||
if (state !== undefined) {patchArgs.push('--raw-field', `state=${state}`)}
|
||||
if (title !== undefined) {
|
||||
patchArgs.push('--raw-field', `title=${title}`)
|
||||
}
|
||||
if (body !== undefined) {
|
||||
patchArgs.push('--raw-field', `body=${body}`)
|
||||
}
|
||||
if (state !== undefined) {
|
||||
patchArgs.push('--raw-field', `state=${state}`)
|
||||
}
|
||||
const r = await runRest<unknown>(patchArgs)
|
||||
if (!r.ok) {return { ok: false, error: r.error }}
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Labels — collapse multi-delete fan-out into a single PUT when removing
|
||||
@@ -194,36 +211,49 @@ export async function updateIssueBySlug(
|
||||
if (removeCount > 1) {
|
||||
type RawLabelResp = { name?: string }[]
|
||||
const fetched = await runRest<RawLabelResp>(['-X', 'GET', `${base}/labels`])
|
||||
if (!fetched.ok) {return { ok: false, error: fetched.error }}
|
||||
if (!fetched.ok) {
|
||||
return { ok: false, error: fetched.error }
|
||||
}
|
||||
const currentNames = new Set(
|
||||
fetched.data.map((l) => l.name).filter((n): n is string => typeof n === 'string')
|
||||
)
|
||||
for (const l of removeLabels ?? []) {currentNames.delete(l)}
|
||||
for (const l of addLabels ?? []) {currentNames.add(l)}
|
||||
for (const l of removeLabels ?? []) {
|
||||
currentNames.delete(l)
|
||||
}
|
||||
for (const l of addLabels ?? []) {
|
||||
currentNames.add(l)
|
||||
}
|
||||
if (currentNames.size === 0) {
|
||||
// Why: `gh api -X PUT` with no `--raw-field` arguments sends an empty
|
||||
// body — GitHub does NOT interpret that as "clear labels". The
|
||||
// dedicated DELETE endpoint is the documented way to remove all
|
||||
// labels in a single call.
|
||||
const r = await runRest<unknown>(
|
||||
['-X', 'DELETE', `${base}/labels`],
|
||||
undefined,
|
||||
'core',
|
||||
{ expectEmpty: true }
|
||||
)
|
||||
if (!r.ok && r.error.type !== 'not_found') {return { ok: false, error: r.error }}
|
||||
const r = await runRest<unknown>(['-X', 'DELETE', `${base}/labels`], undefined, 'core', {
|
||||
expectEmpty: true
|
||||
})
|
||||
if (!r.ok && r.error.type !== 'not_found') {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
} else {
|
||||
const putArgs = ['-X', 'PUT', `${base}/labels`]
|
||||
for (const name of currentNames) {putArgs.push('--raw-field', `labels[]=${name}`)}
|
||||
for (const name of currentNames) {
|
||||
putArgs.push('--raw-field', `labels[]=${name}`)
|
||||
}
|
||||
const r = await runRest<unknown>(putArgs)
|
||||
if (!r.ok) {return { ok: false, error: r.error }}
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (addCount > 0) {
|
||||
const restArgs = ['-X', 'POST', `${base}/labels`]
|
||||
for (const l of addLabels ?? []) {restArgs.push('--raw-field', `labels[]=${l}`)}
|
||||
for (const l of addLabels ?? []) {
|
||||
restArgs.push('--raw-field', `labels[]=${l}`)
|
||||
}
|
||||
const r = await runRest<unknown>(restArgs)
|
||||
if (!r.ok) {return { ok: false, error: r.error }}
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
}
|
||||
if (removeCount === 1) {
|
||||
const r = await runRest<unknown>(
|
||||
@@ -232,7 +262,9 @@ export async function updateIssueBySlug(
|
||||
'core',
|
||||
{ expectEmpty: true }
|
||||
)
|
||||
if (!r.ok && r.error.type !== 'not_found') {return { ok: false, error: r.error }}
|
||||
if (!r.ok && r.error.type !== 'not_found') {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,15 +272,23 @@ export async function updateIssueBySlug(
|
||||
// add/remove are at most 2 calls regardless of array size.
|
||||
if (addAssignees && addAssignees.length > 0) {
|
||||
const restArgs = ['-X', 'POST', `${base}/assignees`]
|
||||
for (const u of addAssignees) {restArgs.push('--raw-field', `assignees[]=${u}`)}
|
||||
for (const u of addAssignees) {
|
||||
restArgs.push('--raw-field', `assignees[]=${u}`)
|
||||
}
|
||||
const r = await runRest<unknown>(restArgs)
|
||||
if (!r.ok) {return { ok: false, error: r.error }}
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
}
|
||||
if (removeAssignees && removeAssignees.length > 0) {
|
||||
const restArgs = ['-X', 'DELETE', `${base}/assignees`]
|
||||
for (const u of removeAssignees) {restArgs.push('--raw-field', `assignees[]=${u}`)}
|
||||
for (const u of removeAssignees) {
|
||||
restArgs.push('--raw-field', `assignees[]=${u}`)
|
||||
}
|
||||
const r = await runRest<unknown>(restArgs)
|
||||
if (!r.ok) {return { ok: false, error: r.error }}
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -257,13 +297,21 @@ export async function updatePullRequestBySlug(
|
||||
args: UpdatePullRequestBySlugArgs
|
||||
): Promise<GitHubProjectMutationResult> {
|
||||
const v = validateSlugArgs(args.owner, args.repo)
|
||||
if (!v.ok) {return v}
|
||||
if (!v.ok) {
|
||||
return v
|
||||
}
|
||||
const n = assertPositiveInt(args.number, 'number')
|
||||
if (!n.ok) {return { ok: false, error: n.error }}
|
||||
if (!n.ok) {
|
||||
return { ok: false, error: n.error }
|
||||
}
|
||||
if (!args.updates || typeof args.updates !== 'object') {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } }
|
||||
}
|
||||
const patchArgs: string[] = ['-X', 'PATCH', `repos/${args.owner}/${args.repo}/pulls/${args.number}`]
|
||||
const patchArgs: string[] = [
|
||||
'-X',
|
||||
'PATCH',
|
||||
`repos/${args.owner}/${args.repo}/pulls/${args.number}`
|
||||
]
|
||||
// Why: count fields explicitly rather than inferring from patchArgs.length —
|
||||
// adding a future header/flag arg silently breaks an array-length check.
|
||||
let fieldCount = 0
|
||||
@@ -280,7 +328,9 @@ export async function updatePullRequestBySlug(
|
||||
return { ok: true }
|
||||
}
|
||||
const r = await runRest<unknown>(patchArgs)
|
||||
if (!r.ok) {return { ok: false, error: r.error }}
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -308,9 +358,13 @@ export async function addIssueCommentBySlug(
|
||||
args: AddIssueCommentBySlugArgs
|
||||
): Promise<GitHubProjectCommentMutationResult> {
|
||||
const v = validateSlugArgs(args.owner, args.repo)
|
||||
if (!v.ok) {return v}
|
||||
if (!v.ok) {
|
||||
return v
|
||||
}
|
||||
const n = assertPositiveInt(args.number, 'number')
|
||||
if (!n.ok) {return { ok: false, error: n.error }}
|
||||
if (!n.ok) {
|
||||
return { ok: false, error: n.error }
|
||||
}
|
||||
if (typeof args.body !== 'string' || !args.body.trim()) {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
|
||||
}
|
||||
@@ -321,7 +375,9 @@ export async function addIssueCommentBySlug(
|
||||
'--raw-field',
|
||||
`body=${args.body}`
|
||||
])
|
||||
if (!r.ok) {return { ok: false, error: r.error }}
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
return { ok: true, comment: mapIssueComment(r.data, args.body) }
|
||||
}
|
||||
|
||||
@@ -329,9 +385,13 @@ export async function updateIssueCommentBySlug(
|
||||
args: UpdateIssueCommentBySlugArgs
|
||||
): Promise<GitHubProjectMutationResult> {
|
||||
const v = validateSlugArgs(args.owner, args.repo)
|
||||
if (!v.ok) {return v}
|
||||
if (!v.ok) {
|
||||
return v
|
||||
}
|
||||
const n = assertPositiveInt(args.commentId, 'commentId')
|
||||
if (!n.ok) {return { ok: false, error: n.error }}
|
||||
if (!n.ok) {
|
||||
return { ok: false, error: n.error }
|
||||
}
|
||||
if (typeof args.body !== 'string' || !args.body.trim()) {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
|
||||
}
|
||||
@@ -342,7 +402,9 @@ export async function updateIssueCommentBySlug(
|
||||
'--raw-field',
|
||||
`body=${args.body}`
|
||||
])
|
||||
if (!r.ok) {return { ok: false, error: r.error }}
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -350,16 +412,22 @@ export async function deleteIssueCommentBySlug(
|
||||
args: DeleteIssueCommentBySlugArgs
|
||||
): Promise<GitHubProjectMutationResult> {
|
||||
const v = validateSlugArgs(args.owner, args.repo)
|
||||
if (!v.ok) {return v}
|
||||
if (!v.ok) {
|
||||
return v
|
||||
}
|
||||
const n = assertPositiveInt(args.commentId, 'commentId')
|
||||
if (!n.ok) {return { ok: false, error: n.error }}
|
||||
if (!n.ok) {
|
||||
return { ok: false, error: n.error }
|
||||
}
|
||||
const r = await runRest<unknown>(
|
||||
['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`],
|
||||
undefined,
|
||||
'core',
|
||||
{ expectEmpty: true }
|
||||
)
|
||||
if (!r.ok) {return { ok: false, error: r.error }}
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -369,9 +437,13 @@ export async function listLabelsBySlug(
|
||||
args: ListLabelsBySlugArgs
|
||||
): Promise<ListLabelsBySlugResult> {
|
||||
const v = validateSlugArgs(args.owner, args.repo)
|
||||
if (!v.ok) {return v}
|
||||
if (!v.ok) {
|
||||
return v
|
||||
}
|
||||
const guard = rateLimitGuard('core')
|
||||
if (guard.blocked) {return { ok: false, error: rateLimitedError(guard) }}
|
||||
if (guard.blocked) {
|
||||
return { ok: false, error: rateLimitedError(guard) }
|
||||
}
|
||||
await acquire()
|
||||
// Why: `--paginate` may fan out to multiple pages; we can only reasonably
|
||||
// estimate a 1-call spend up front. The next probe will reconcile.
|
||||
@@ -400,12 +472,16 @@ export async function listAssignableUsersBySlug(
|
||||
args: ListAssignableUsersBySlugArgs
|
||||
): Promise<ListAssignableUsersBySlugResult> {
|
||||
const v = validateSlugArgs(args.owner, args.repo)
|
||||
if (!v.ok) {return v}
|
||||
if (!v.ok) {
|
||||
return v
|
||||
}
|
||||
// Seed logins merge after the fetch so callers can include currently-visible
|
||||
// assignees even if the repo participant search is sparse.
|
||||
const result: GitHubAssignableUser[] = []
|
||||
const guard = rateLimitGuard('core')
|
||||
if (guard.blocked) {return { ok: false, error: rateLimitedError(guard) }}
|
||||
if (guard.blocked) {
|
||||
return { ok: false, error: rateLimitedError(guard) }
|
||||
}
|
||||
await acquire()
|
||||
noteRateLimitSpend('core')
|
||||
try {
|
||||
@@ -419,7 +495,10 @@ export async function listAssignableUsersBySlug(
|
||||
],
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
for (const line of stdout.trim().split('\n').filter((l) => l.length > 0)) {
|
||||
for (const line of stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((l) => l.length > 0)) {
|
||||
try {
|
||||
const u = JSON.parse(line) as { login?: string; avatarUrl?: string; name?: string | null }
|
||||
if (typeof u.login === 'string') {
|
||||
@@ -455,7 +534,9 @@ export async function listIssueTypesBySlug(
|
||||
args: ListIssueTypesBySlugArgs
|
||||
): Promise<ListIssueTypesBySlugResult> {
|
||||
const v = validateSlugArgs(args.owner, args.repo)
|
||||
if (!v.ok) {return v}
|
||||
if (!v.ok) {
|
||||
return v
|
||||
}
|
||||
const query = `
|
||||
query($owner:String!, $repo:String!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
@@ -488,7 +569,10 @@ export async function listIssueTypesBySlug(
|
||||
}
|
||||
const nodes = res.data.repository?.issueTypes?.nodes ?? []
|
||||
const types = nodes
|
||||
.filter((n): n is NonNullable<typeof n> => n !== null && typeof n.id === 'string' && typeof n.name === 'string')
|
||||
.filter(
|
||||
(n): n is NonNullable<typeof n> =>
|
||||
n !== null && typeof n.id === 'string' && typeof n.name === 'string'
|
||||
)
|
||||
.map((n) => ({
|
||||
id: n.id as string,
|
||||
name: n.name as string,
|
||||
@@ -502,9 +586,13 @@ export async function updateIssueTypeBySlug(
|
||||
args: UpdateIssueTypeBySlugArgs
|
||||
): Promise<GitHubProjectMutationResult> {
|
||||
const v = validateSlugArgs(args.owner, args.repo)
|
||||
if (!v.ok) {return v}
|
||||
if (!v.ok) {
|
||||
return v
|
||||
}
|
||||
const n = assertPositiveInt(args.number, 'number')
|
||||
if (!n.ok) {return { ok: false, error: n.error }}
|
||||
if (!n.ok) {
|
||||
return { ok: false, error: n.error }
|
||||
}
|
||||
// Why: `updateIssueIssueType` is the dedicated mutation; passing null for
|
||||
// `issueTypeId` clears the type. We resolve the issue id via a lightweight
|
||||
// GraphQL lookup because the REST endpoint doesn't accept issue types.
|
||||
@@ -516,7 +604,9 @@ export async function updateIssueTypeBySlug(
|
||||
}`,
|
||||
{ owner: args.owner, repo: args.repo, num: args.number }
|
||||
)
|
||||
if (!lookup.ok) {return { ok: false, error: lookup.error }}
|
||||
if (!lookup.ok) {
|
||||
return { ok: false, error: lookup.error }
|
||||
}
|
||||
const issueId = lookup.data.repository?.issue?.id
|
||||
if (!issueId) {
|
||||
return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } }
|
||||
@@ -543,7 +633,9 @@ export async function updateIssueTypeBySlug(
|
||||
? { issueId, issueTypeId: args.issueTypeId }
|
||||
: { issueId }
|
||||
const res = await runGraphql<unknown>(query, vars)
|
||||
if (!res.ok) {return { ok: false, error: res.error }}
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -567,9 +659,13 @@ export async function getWorkItemDetailsBySlug(
|
||||
args: ProjectWorkItemDetailsBySlugArgs
|
||||
): Promise<ProjectWorkItemDetailsBySlugResult> {
|
||||
const v = validateSlugArgs(args.owner, args.repo)
|
||||
if (!v.ok) {return v}
|
||||
if (!v.ok) {
|
||||
return v
|
||||
}
|
||||
const n = assertPositiveInt(args.number, 'number')
|
||||
if (!n.ok) {return { ok: false, error: n.error }}
|
||||
if (!n.ok) {
|
||||
return { ok: false, error: n.error }
|
||||
}
|
||||
if (args.type !== 'issue' && args.type !== 'pr') {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } }
|
||||
}
|
||||
@@ -620,41 +716,47 @@ export async function getWorkItemDetailsBySlug(
|
||||
`
|
||||
const res = await runGraphql<{
|
||||
repository?: {
|
||||
issue?: RawWorkItemContent & {
|
||||
updatedAt?: string
|
||||
body?: string
|
||||
author?: { login?: string } | null
|
||||
participants?: { nodes?: RawUser[] }
|
||||
comments?: {
|
||||
nodes?: ({
|
||||
databaseId?: number
|
||||
author?: { login?: string; avatarUrl?: string; __typename?: string } | null
|
||||
issue?:
|
||||
| (RawWorkItemContent & {
|
||||
updatedAt?: string
|
||||
body?: string
|
||||
createdAt?: string
|
||||
url?: string
|
||||
} | null)[]
|
||||
}
|
||||
} | null
|
||||
pullRequest?: RawWorkItemContent & {
|
||||
updatedAt?: string
|
||||
body?: string
|
||||
headRefName?: string
|
||||
baseRefName?: string
|
||||
author?: { login?: string } | null
|
||||
participants?: { nodes?: RawUser[] }
|
||||
comments?: {
|
||||
nodes?: ({
|
||||
databaseId?: number
|
||||
author?: { login?: string; avatarUrl?: string; __typename?: string } | null
|
||||
author?: { login?: string } | null
|
||||
participants?: { nodes?: RawUser[] }
|
||||
comments?: {
|
||||
nodes?: ({
|
||||
databaseId?: number
|
||||
author?: { login?: string; avatarUrl?: string; __typename?: string } | null
|
||||
body?: string
|
||||
createdAt?: string
|
||||
url?: string
|
||||
} | null)[]
|
||||
}
|
||||
})
|
||||
| null
|
||||
pullRequest?:
|
||||
| (RawWorkItemContent & {
|
||||
updatedAt?: string
|
||||
body?: string
|
||||
createdAt?: string
|
||||
url?: string
|
||||
} | null)[]
|
||||
}
|
||||
} | null
|
||||
headRefName?: string
|
||||
baseRefName?: string
|
||||
author?: { login?: string } | null
|
||||
participants?: { nodes?: RawUser[] }
|
||||
comments?: {
|
||||
nodes?: ({
|
||||
databaseId?: number
|
||||
author?: { login?: string; avatarUrl?: string; __typename?: string } | null
|
||||
body?: string
|
||||
createdAt?: string
|
||||
url?: string
|
||||
} | null)[]
|
||||
}
|
||||
})
|
||||
| null
|
||||
} | null
|
||||
}>(query, { owner: args.owner, repo: args.repo, num: args.number })
|
||||
if (!res.ok) {return { ok: false, error: res.error }}
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
const raw = args.type === 'issue' ? res.data.repository?.issue : res.data.repository?.pullRequest
|
||||
if (!raw) {
|
||||
return { ok: false, error: { type: 'not_found', message: 'Item not found.' } }
|
||||
@@ -668,7 +770,9 @@ export async function getWorkItemDetailsBySlug(
|
||||
.filter((l): l is string => typeof l === 'string')
|
||||
const comments: PRComment[] = []
|
||||
for (const c of raw.comments?.nodes ?? []) {
|
||||
if (!c || typeof c.body !== 'string') {continue}
|
||||
if (!c || typeof c.body !== 'string') {
|
||||
continue
|
||||
}
|
||||
comments.push({
|
||||
id: typeof c.databaseId === 'number' ? c.databaseId : Date.now(),
|
||||
author: c.author?.login ?? '',
|
||||
@@ -735,4 +839,3 @@ export async function getWorkItemDetailsBySlug(
|
||||
}
|
||||
return { ok: true, details }
|
||||
}
|
||||
|
||||
|
||||
@@ -37,11 +37,15 @@ type GhRateLimitPayload = {
|
||||
}
|
||||
}
|
||||
|
||||
function parseBucket(raw: {
|
||||
limit?: number
|
||||
remaining?: number
|
||||
reset?: number
|
||||
} | undefined): GitHubRateLimitBucket {
|
||||
function parseBucket(
|
||||
raw:
|
||||
| {
|
||||
limit?: number
|
||||
remaining?: number
|
||||
reset?: number
|
||||
}
|
||||
| undefined
|
||||
): GitHubRateLimitBucket {
|
||||
// Why: if a bucket is absent from the response (old gh, partial response),
|
||||
// return 0/0/now so the UI shows a clear "unknown" state (0/0 is
|
||||
// unambiguous) rather than a misleading "plenty left" fallback.
|
||||
@@ -81,12 +85,14 @@ export type RateLimitBucketKind = 'core' | 'graphql' | 'search'
|
||||
* it advisory (returns a reason, doesn't throw) so callers can format the
|
||||
* envelope/error in their own shape.
|
||||
*/
|
||||
export function rateLimitGuard(bucket: RateLimitBucketKind): { blocked: false } | {
|
||||
blocked: true
|
||||
remaining: number
|
||||
limit: number
|
||||
resetAt: number
|
||||
} {
|
||||
export function rateLimitGuard(bucket: RateLimitBucketKind):
|
||||
| { blocked: false }
|
||||
| {
|
||||
blocked: true
|
||||
remaining: number
|
||||
limit: number
|
||||
resetAt: number
|
||||
} {
|
||||
if (!cached) {
|
||||
return { blocked: false }
|
||||
}
|
||||
|
||||
+19
-1
@@ -16,7 +16,8 @@ import { setAppRuntimeFlags } from './ipc/app'
|
||||
import { closeAllWatchers } from './ipc/filesystem-watcher'
|
||||
import { registerCoreHandlers } from './ipc/register-core-handlers'
|
||||
import { registerMobileHandlers } from './ipc/mobile'
|
||||
import { initTelemetry, shutdownTelemetry } from './telemetry/client'
|
||||
import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce } from './telemetry/client'
|
||||
import { resolveConsent } from './telemetry/consent'
|
||||
import { triggerStartupNotificationRegistration } from './ipc/notifications'
|
||||
import { OrcaRuntimeService } from './runtime/orca-runtime'
|
||||
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
|
||||
@@ -216,6 +217,22 @@ function openMainWindow(): BrowserWindow {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: telemetry-plan.md§First-launch experience anchors default-on
|
||||
// `app_opened` to the first main-window load. Existing users in the
|
||||
// pending-banner cohort resolve through telemetry/client.ts; this load
|
||||
// path only fires once consent is already enabled.
|
||||
const onFirstWindowLoad = (): void => {
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
const consent = resolveConsent(store.getSettings())
|
||||
if (consent.effective !== 'enabled') {
|
||||
return
|
||||
}
|
||||
trackAppOpenedOnce()
|
||||
}
|
||||
window.webContents.on('did-finish-load', onFirstWindowLoad)
|
||||
|
||||
registerCoreHandlers(
|
||||
store,
|
||||
runtime,
|
||||
@@ -422,6 +439,7 @@ app.whenReady().then(async () => {
|
||||
// and defeat the teardown helper's prefix sweep (design §4.3 wire-up).
|
||||
getLocalProvider: () => getLocalPtyProvider()
|
||||
})
|
||||
runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
|
||||
starNag = new StarNagService(store, stats)
|
||||
starNag.start()
|
||||
starNag.registerIpcHandlers()
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('registerGitHubHandlers', () => {
|
||||
branch: 'feature/test'
|
||||
})
|
||||
|
||||
expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/test')
|
||||
expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/test', null)
|
||||
})
|
||||
|
||||
it('rejects unknown repository paths', async () => {
|
||||
|
||||
+18
-15
@@ -84,22 +84,25 @@ function assertRegisteredRepo(repoPath: string, store: Store): Repo {
|
||||
}
|
||||
|
||||
export function registerGitHubHandlers(store: Store, stats: StatsCollector): void {
|
||||
ipcMain.handle('gh:prForBranch', async (_event, args: { repoPath: string; branch: string }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
const pr = await getPRForBranch(repo.path, args.branch)
|
||||
// Emit pr_created when a PR is first detected for a branch.
|
||||
// Why here: the renderer polls gh:prForBranch to check PR status per worktree.
|
||||
// This captures PRs opened from any workflow (Orca UI, gh CLI, github.com).
|
||||
if (pr && !stats.hasCountedPR(pr.url)) {
|
||||
stats.record({
|
||||
type: 'pr_created',
|
||||
at: Date.now(),
|
||||
repoId: repo.id,
|
||||
meta: { prNumber: pr.number, prUrl: pr.url }
|
||||
})
|
||||
ipcMain.handle(
|
||||
'gh:prForBranch',
|
||||
async (_event, args: { repoPath: string; branch: string; linkedPRNumber?: number | null }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
const pr = await getPRForBranch(repo.path, args.branch, args.linkedPRNumber ?? null)
|
||||
// Emit pr_created when a PR is first detected for a branch.
|
||||
// Why here: the renderer polls gh:prForBranch to check PR status per worktree.
|
||||
// This captures PRs opened from any workflow (Orca UI, gh CLI, github.com).
|
||||
if (pr && !stats.hasCountedPR(pr.url)) {
|
||||
stats.record({
|
||||
type: 'pr_created',
|
||||
at: Date.now(),
|
||||
repoId: repo.id,
|
||||
meta: { prNumber: pr.number, prUrl: pr.url }
|
||||
})
|
||||
}
|
||||
return pr
|
||||
}
|
||||
return pr
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:issue', (_event, args: { repoPath: string; number: number }) => {
|
||||
const repo = assertRegisteredRepo(args.repoPath, store)
|
||||
|
||||
@@ -82,6 +82,7 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
|
||||
return {
|
||||
available: true as const,
|
||||
qrDataUrl,
|
||||
pairingUrl: url,
|
||||
endpoint,
|
||||
deviceId: device.deviceId
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ const {
|
||||
buildAgentHookEnvMock,
|
||||
piBuildPtyEnvMock,
|
||||
piClearPtyMock,
|
||||
isPwshAvailableMock
|
||||
isPwshAvailableMock,
|
||||
trackMock,
|
||||
classifyErrorMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
onMock: vi.fn(),
|
||||
@@ -40,7 +42,9 @@ const {
|
||||
openCodeClearPtyMock: vi.fn(),
|
||||
buildAgentHookEnvMock: vi.fn(),
|
||||
piBuildPtyEnvMock: vi.fn(),
|
||||
piClearPtyMock: vi.fn()
|
||||
piClearPtyMock: vi.fn(),
|
||||
trackMock: vi.fn(),
|
||||
classifyErrorMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -95,6 +99,14 @@ vi.mock('../pi/titlebar-extension-service', () => ({
|
||||
vi.mock('../pwsh', () => ({
|
||||
isPwshAvailable: isPwshAvailableMock
|
||||
}))
|
||||
|
||||
vi.mock('../telemetry/client', () => ({
|
||||
track: trackMock
|
||||
}))
|
||||
|
||||
vi.mock('../telemetry/classify-error', () => ({
|
||||
classifyError: classifyErrorMock
|
||||
}))
|
||||
import { LocalPtyProvider } from '../providers/local-pty-provider'
|
||||
import {
|
||||
registerPtyHandlers,
|
||||
@@ -142,6 +154,8 @@ describe('registerPtyHandlers', () => {
|
||||
piBuildPtyEnvMock.mockReset()
|
||||
piClearPtyMock.mockReset()
|
||||
isPwshAvailableMock.mockReset()
|
||||
trackMock.mockReset()
|
||||
classifyErrorMock.mockReset()
|
||||
mainWindow.webContents.on.mockReset()
|
||||
mainWindow.webContents.send.mockReset()
|
||||
|
||||
@@ -1629,4 +1643,86 @@ describe('registerPtyHandlers', () => {
|
||||
expect(openCodeClearPtyMock).toHaveBeenCalledWith(spawnResult.id)
|
||||
expect(piClearPtyMock).toHaveBeenCalledWith(spawnResult.id)
|
||||
})
|
||||
|
||||
describe('agent_started telemetry', () => {
|
||||
// Why: telemetry-plan.md§Agent launch semantics — agent_started must
|
||||
// fire only after provider.spawn resolves. The renderer threads
|
||||
// launch metadata through `pty:spawn`; a missing or malformed
|
||||
// payload must not produce a silently-malformed event.
|
||||
it('emits agent_started after a successful spawn when telemetry is supplied', async () => {
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
telemetry: {
|
||||
agent_kind: 'claude-code',
|
||||
launch_source: 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
}
|
||||
})
|
||||
expect(trackMock).toHaveBeenCalledWith('agent_started', {
|
||||
agent_kind: 'claude-code',
|
||||
launch_source: 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not emit agent_started when telemetry is omitted (bare-shell tab)', async () => {
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
expect(trackMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops the event when any telemetry field is outside its closed enum', async () => {
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
telemetry: {
|
||||
agent_kind: 'claude-code',
|
||||
launch_source: 'not_a_real_surface',
|
||||
request_kind: 'new'
|
||||
}
|
||||
})
|
||||
expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything())
|
||||
})
|
||||
|
||||
it('does not emit agent_started when provider.spawn throws', async () => {
|
||||
// Why: telemetry-plan contract is that agent_started fires only on
|
||||
// confirmed launch. Inject a provider whose spawn throws so we hit
|
||||
// the catch path with no race against the real LocalPtyProvider.
|
||||
setLocalPtyProvider({
|
||||
spawn: vi.fn(async () => {
|
||||
throw new Error('spawn boom')
|
||||
}),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
onData: vi.fn(() => vi.fn()),
|
||||
onExit: vi.fn(() => vi.fn()),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
getForegroundProcess: vi.fn(async () => null)
|
||||
} as never)
|
||||
classifyErrorMock.mockReturnValue({ error_class: 'unknown' })
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
await expect(
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
command: 'claude',
|
||||
telemetry: {
|
||||
agent_kind: 'claude-code',
|
||||
launch_source: 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/spawn boom/)
|
||||
expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,13 @@ import {
|
||||
} from '../claude-accounts/live-pty-gate'
|
||||
import { applyTerminalAttributionEnv } from '../attribution/terminal-attribution'
|
||||
import { registerPty, unregisterPty } from '../memory/pty-registry'
|
||||
import { track } from '../telemetry/client'
|
||||
import { classifyError } from '../telemetry/classify-error'
|
||||
import {
|
||||
agentKindSchema,
|
||||
launchSourceSchema,
|
||||
requestKindSchema
|
||||
} from '../../shared/telemetry-events'
|
||||
|
||||
// ─── Provider Registry ──────────────────────────────────────────────
|
||||
// Routes PTY operations by connectionId. null = local provider.
|
||||
@@ -715,6 +722,17 @@ export function registerPtyHandlers(
|
||||
worktreeId?: string
|
||||
sessionId?: string
|
||||
shellOverride?: string
|
||||
// Why: telemetry-plan.md§Agent launch semantics. The renderer
|
||||
// threads what Orca was *asked* to launch through this field; main
|
||||
// fires `agent_started` only after `provider.spawn` resolves. Loose
|
||||
// typing on the IPC boundary because the main-side schema
|
||||
// validator is the single enforcement point — `track()` will drop
|
||||
// the event if any field is outside its closed enum.
|
||||
telemetry?: {
|
||||
agent_kind?: unknown
|
||||
launch_source?: unknown
|
||||
request_kind?: unknown
|
||||
}
|
||||
}
|
||||
) => {
|
||||
const provider = getProvider(args.connectionId)
|
||||
@@ -879,6 +897,30 @@ export function registerPtyHandlers(
|
||||
if (isMintedSessionId && effectiveSessionId !== undefined) {
|
||||
clearProviderPtyState(effectiveSessionId)
|
||||
}
|
||||
// Why: telemetry-plan.md§agent_error — when the renderer threaded
|
||||
// agent_kind through args.telemetry, attribute the error to that agent.
|
||||
// Otherwise fall back to sniffing the command for `claude` (the one
|
||||
// agent the main process can identify on its own via the existing
|
||||
// `isClaudeLaunchCommand` regex used for auth gating). Bare-shell
|
||||
// catches and unknown-agent catches without renderer telemetry remain
|
||||
// unattributed. The event still emits with a classified `error_class`;
|
||||
// raw error messages are dropped at the telemetry validator boundary.
|
||||
const rendererAgentKindParse =
|
||||
args.telemetry?.agent_kind !== undefined
|
||||
? agentKindSchema.safeParse(args.telemetry.agent_kind)
|
||||
: null
|
||||
const errorAgentKind = rendererAgentKindParse?.success
|
||||
? rendererAgentKindParse.data
|
||||
: isClaudeLaunch
|
||||
? ('claude-code' as const)
|
||||
: null
|
||||
if (errorAgentKind) {
|
||||
const classified = classifyError(err)
|
||||
track('agent_error', {
|
||||
agent_kind: errorAgentKind,
|
||||
error_class: classified.error_class
|
||||
})
|
||||
}
|
||||
throw err
|
||||
}
|
||||
ptyOwnership.set(result.id, args.connectionId ?? null)
|
||||
@@ -993,6 +1035,28 @@ export function registerPtyHandlers(
|
||||
: null
|
||||
})
|
||||
}
|
||||
// Why: telemetry-plan.md§Agent launch semantics — fire `agent_started`
|
||||
// only after `provider.spawn` resolved. The renderer threads
|
||||
// `args.telemetry` through the spawn IPC for every launch we want to
|
||||
// attribute; bare-shell tabs (no agent) leave the field undefined and
|
||||
// do not produce an event. Each field is parsed against its closed
|
||||
// enum here so a malformed renderer payload (or a spoofed IPC) does
|
||||
// not poison the event — `safeParse` failure drops that field, and
|
||||
// if any required field is missing we skip the event entirely. The
|
||||
// main-side `track()` validator re-runs the schema on the full
|
||||
// payload as a second defense-in-depth check.
|
||||
if (args.telemetry) {
|
||||
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
|
||||
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
|
||||
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
|
||||
if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) {
|
||||
track('agent_started', {
|
||||
agent_kind: agentKindParse.data,
|
||||
launch_source: launchSourceParse.data,
|
||||
request_kind: requestKindParse.data
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
@@ -29,6 +29,25 @@ import {
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { getActiveMultiplexer } from './ssh'
|
||||
import { normalizeSparseDirectories } from './sparse-checkout-directories'
|
||||
import { track } from '../telemetry/client'
|
||||
import type { RepoMethod } from '../../shared/telemetry-events'
|
||||
|
||||
// Why: `method` answers "which entry point did the user take?", not "what did
|
||||
// they add?" — so the IPC the renderer invoked IS the method. We never send
|
||||
// the path, URL, or display name. `repos:create` collapses into
|
||||
// `folder_picker` because the user's entry was the folder picker, even
|
||||
// though main also `git init`s. `drag_drop` is reserved for a future call
|
||||
// site; no current renderer surface produces it.
|
||||
function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean): void {
|
||||
// Why: re-adding an existing repo (matched by path inside the handler)
|
||||
// is not a new activation event. Suppressing the duplicate keeps the
|
||||
// funnel honest and avoids inflating `repo_added` for users who
|
||||
// re-pick the same folder.
|
||||
if (alreadyExisted) {
|
||||
return
|
||||
}
|
||||
track('repo_added', { method })
|
||||
}
|
||||
|
||||
// Why: module-scoped so the abort handle survives window re-creation on macOS.
|
||||
// registerRepoHandlers is called again when a new BrowserWindow is created,
|
||||
@@ -74,6 +93,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
// Check if already added
|
||||
const existing = store.getRepos().find((r) => r.path === args.path)
|
||||
if (existing) {
|
||||
emitRepoAdded('folder_picker', true)
|
||||
return { repo: existing }
|
||||
}
|
||||
|
||||
@@ -89,6 +109,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
store.addRepo(repo)
|
||||
await rebuildAuthorizedRootsCache(store)
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('folder_picker', false)
|
||||
return { repo }
|
||||
}
|
||||
)
|
||||
@@ -135,6 +156,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
.getRepos()
|
||||
.find((r) => r.connectionId === args.connectionId && r.path === resolvedPath)
|
||||
if (existing) {
|
||||
emitRepoAdded('folder_picker', true)
|
||||
return { repo: existing }
|
||||
}
|
||||
|
||||
@@ -195,6 +217,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
mux.notify('session.registerRoot', { rootPath: resolvedPath })
|
||||
}
|
||||
|
||||
emitRepoAdded('folder_picker', false)
|
||||
return { repo }
|
||||
}
|
||||
)
|
||||
@@ -241,6 +264,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
// the race matters even after this one passes.
|
||||
const existing = store.getRepos().find((r) => r.path === targetPath)
|
||||
if (existing) {
|
||||
emitRepoAdded('folder_picker', true)
|
||||
return { repo: existing }
|
||||
}
|
||||
|
||||
@@ -382,6 +406,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
store.addRepo(repo)
|
||||
await rebuildAuthorizedRootsCache(store)
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('folder_picker', false)
|
||||
return { repo }
|
||||
}
|
||||
)
|
||||
@@ -621,9 +646,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
const updated = store.updateRepo(existing.id, { kind: 'git' })
|
||||
if (updated) {
|
||||
notifyReposChanged(mainWindow)
|
||||
// Why: folder→git upgrade is a real new git repo provisioning event.
|
||||
emitRepoAdded('clone_url', false)
|
||||
return updated
|
||||
}
|
||||
}
|
||||
emitRepoAdded('clone_url', true)
|
||||
return existing
|
||||
}
|
||||
|
||||
@@ -639,6 +667,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
store.addRepo(repo)
|
||||
await rebuildAuthorizedRootsCache(store)
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('clone_url', false)
|
||||
return repo
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,6 +4,13 @@ import type { GlobalSettings, PersistedState } from '../../shared/types'
|
||||
import { listSystemFontFamilies } from '../system-fonts'
|
||||
import { previewGhosttyImport } from '../ghostty/index'
|
||||
import { rebuildAppMenu } from '../menu/register-app-menu'
|
||||
import { track } from '../telemetry/client'
|
||||
import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../shared/telemetry-events'
|
||||
|
||||
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
|
||||
// to a Set once at module load lets the IPC handler's per-key membership
|
||||
// check stay O(1) without re-coercing the readonly tuple on every call.
|
||||
const SETTINGS_CHANGED_WHITELIST_SET = new Set<string>(SETTINGS_CHANGED_WHITELIST)
|
||||
|
||||
// Why: fields that appear in the View > Appearance submenu need the menu
|
||||
// rebuilt after any update so the checkbox `checked` state stays in sync
|
||||
@@ -23,10 +30,43 @@ export function registerSettingsHandlers(store: Store): void {
|
||||
if (args.theme) {
|
||||
nativeTheme.themeSource = args.theme
|
||||
}
|
||||
// Why: capture the pre-update value so we only emit when the value
|
||||
// actually changes. The settings UI sometimes re-saves the same value
|
||||
// (e.g. blur after a no-op edit), and a `settings_changed` event for a
|
||||
// no-op flip would inflate the experimental-feature-adoption signal.
|
||||
const before = store.getSettings()
|
||||
const result = store.updateSettings(args)
|
||||
if (APPEARANCE_MENU_KEYS.some((key) => key in args)) {
|
||||
rebuildAppMenu()
|
||||
}
|
||||
|
||||
// Why: telemetry-plan.md§Settings — fire `settings_changed` only for
|
||||
// whitelisted keys, with `value_kind` distinguishing booleans from
|
||||
// string-enum settings. We deliberately do NOT send the raw value for
|
||||
// non-enum settings; the whitelist is currently scoped to experimental
|
||||
// toggles, all of which are booleans, so `value_kind === 'bool'` is
|
||||
// the path the v1 enum has a slot for. If a non-bool whitelisted
|
||||
// setting is ever added, extend the discriminator here at the same
|
||||
// time the schema's `value_kind` enum gains the new value.
|
||||
for (const key of Object.keys(args)) {
|
||||
if (!SETTINGS_CHANGED_WHITELIST_SET.has(key)) {
|
||||
continue
|
||||
}
|
||||
const beforeValue = (before as Record<string, unknown>)[key]
|
||||
const afterValue = (result as Record<string, unknown>)[key]
|
||||
if (beforeValue === afterValue) {
|
||||
continue
|
||||
}
|
||||
if (typeof afterValue !== 'boolean') {
|
||||
// No non-bool whitelist entries today; skip rather than guess.
|
||||
continue
|
||||
}
|
||||
track('settings_changed', {
|
||||
setting_key: key as SettingsChangedKey,
|
||||
value_kind: 'bool'
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
|
||||
+348
-27
@@ -1,7 +1,9 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain } from 'electron'
|
||||
import { copyFile, mkdir, readFile, rm, stat } from 'node:fs/promises'
|
||||
/* eslint-disable max-lines */
|
||||
import { app, BrowserWindow, dialog, ipcMain, nativeImage } from 'electron'
|
||||
import { copyFile, mkdir, readFile, rename, rm, stat, lstat } from 'node:fs/promises'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { basename, extname, join, normalize, sep } from 'node:path'
|
||||
import { basename, dirname, extname, isAbsolute, join, normalize, resolve, sep } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import type { CustomSidekick } from '../../shared/types'
|
||||
|
||||
// Why: image-only sidekick uploads. Static + animated variants render natively
|
||||
@@ -35,36 +37,125 @@ function getSidekicksDir(): string {
|
||||
}
|
||||
|
||||
const MAX_BYTES = 64 * 1024 * 1024 // 64 MB — generous but bounded so a user can't point at a multi-GB file and OOM the renderer when it builds a Blob URL.
|
||||
const MAX_MANIFEST_BYTES = 64 * 1024 // pet.json is tiny by spec; cap to defend against a malicious bundle stuffing megabytes into the manifest.
|
||||
|
||||
function isSafeId(id: string): boolean {
|
||||
// UUIDs only; blocks path traversal and unexpected characters.
|
||||
// UUIDs only; blocks path traversal and unexpected characters. Storage ids
|
||||
// are always generated in main (never sourced from the bundle's manifest.id),
|
||||
// so this regex is the canonical gate for any fs path that includes the id.
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
|
||||
}
|
||||
|
||||
function resolveSidekickFile(id: string, fileName: string): string | null {
|
||||
function resolveSidekickFile(
|
||||
id: string,
|
||||
fileName: string,
|
||||
kind: 'image' | 'bundle'
|
||||
): string | null {
|
||||
if (!isSafeId(id)) {
|
||||
return null
|
||||
}
|
||||
// Why: the renderer hands back the persisted fileName (which includes the
|
||||
// original extension so Blob MIME detection works). We still normalize and
|
||||
// prefix-check against the sidekicks dir to defend against any edge case that
|
||||
// slipped the id regex.
|
||||
const safeName = basename(fileName)
|
||||
const root = normalize(getSidekicksDir())
|
||||
let filePath: string
|
||||
if (kind === 'bundle') {
|
||||
// Bundle layout: custom/<id>/<fileName>. fileName is just the spritesheet
|
||||
// basename (e.g. "spritesheet.webp"); pet.json is read by main only and
|
||||
// never served to the renderer.
|
||||
filePath = normalize(join(root, id, safeName))
|
||||
const bundleDir = normalize(join(root, id)) + sep
|
||||
if (!filePath.startsWith(bundleDir)) {
|
||||
return null
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
// Legacy image layout: custom/<id>.<ext>. Filename must start with the id
|
||||
// so the prefix check catches any edge case that slipped the regex.
|
||||
if (!safeName.startsWith(`${id}.`)) {
|
||||
return null
|
||||
}
|
||||
const filePath = normalize(join(getSidekicksDir(), safeName))
|
||||
if (!filePath.startsWith(normalize(getSidekicksDir()) + sep)) {
|
||||
filePath = normalize(join(root, safeName))
|
||||
if (!filePath.startsWith(root + sep)) {
|
||||
return null
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
const PetManifestSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(128).optional(),
|
||||
displayName: z.string().min(1).max(120).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
spritesheetPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
// Why: belt-and-suspenders against malicious manifests — downstream
|
||||
// resolve+prefix check still runs as defense in depth.
|
||||
.refine(
|
||||
(p) => !p.includes('\0') && !p.startsWith('/') && !p.startsWith('\\') && !p.includes('..'),
|
||||
'invalid spritesheetPath'
|
||||
),
|
||||
frame: z
|
||||
.object({
|
||||
width: z.number().int().positive().max(1024),
|
||||
height: z.number().int().positive().max(1024)
|
||||
})
|
||||
.optional(),
|
||||
fps: z.number().positive().max(60).optional(),
|
||||
defaultAnimation: z.string().min(1).max(64).optional(),
|
||||
animations: z
|
||||
.record(
|
||||
z.string().min(1).max(64),
|
||||
z.object({
|
||||
row: z.number().int().min(0).max(256),
|
||||
frames: z.number().int().positive().max(512)
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
// Why: tolerate unknown top-level fields. Pet-bundle generators may emit
|
||||
// descriptive metadata (e.g. "kind": "person") we don't consume yet, and
|
||||
// strict mode would reject those imports with a confusing "unrecognized
|
||||
// key" error instead of just ignoring the extras.
|
||||
.loose()
|
||||
|
||||
type PetManifest = z.infer<typeof PetManifestSchema>
|
||||
|
||||
// Why: renderer-supplied IPC inputs are untrusted — validate shape before any
|
||||
// path resolution. resolveSidekickFile still gates the actual filesystem path.
|
||||
const SidekickFileRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
fileName: z.string(),
|
||||
kind: z.enum(['image', 'bundle']).optional()
|
||||
})
|
||||
|
||||
async function readSheetDimensions(
|
||||
buffer: Buffer
|
||||
): Promise<{ width: number; height: number } | null> {
|
||||
// Why: nativeImage decodes PNG/JPEG/GIF/WebP/BMP. SVG isn't supported here
|
||||
// (vector → no integer pixel grid), so pet bundles must use a raster sheet.
|
||||
const image = nativeImage.createFromBuffer(buffer)
|
||||
if (image.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
const size = image.getSize()
|
||||
if (size.width <= 0 || size.height <= 0) {
|
||||
return null
|
||||
}
|
||||
return { width: size.width, height: size.height }
|
||||
}
|
||||
|
||||
async function isSymlink(path: string): Promise<boolean> {
|
||||
try {
|
||||
const s = await lstat(path)
|
||||
return s.isSymbolicLink()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSidekickHandlers(): void {
|
||||
ipcMain.handle('sidekick:import', async (event): Promise<CustomSidekick | null> => {
|
||||
// Why: parent the file picker to the sender window so the dialog opens as
|
||||
// a sheet attached to the main window. Without a parent, on macOS the
|
||||
// dialog can land behind the main window.
|
||||
const senderWindow =
|
||||
BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow()
|
||||
const options: Electron.OpenDialogOptions = {
|
||||
@@ -129,14 +220,215 @@ export function registerSidekickHandlers(): void {
|
||||
id,
|
||||
label,
|
||||
fileName,
|
||||
mimeType: classified.mimeType
|
||||
mimeType: classified.mimeType,
|
||||
kind: 'image'
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('sidekick:importPetBundle', async (event): Promise<CustomSidekick | null> => {
|
||||
const senderWindow =
|
||||
BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow()
|
||||
// Why: the bundle is a folder. macOS users may also pick `pet.json` itself
|
||||
// when Finder is set to show package contents — the post-pick logic walks
|
||||
// up to the parent directory in that case.
|
||||
const options: Electron.OpenDialogOptions = {
|
||||
title: 'Pick a .codex-pet bundle',
|
||||
properties: ['openFile', 'openDirectory', 'treatPackageAsDirectory']
|
||||
}
|
||||
const result = senderWindow
|
||||
? await dialog.showOpenDialog(senderWindow, options)
|
||||
: await dialog.showOpenDialog(options)
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
const picked = result.filePaths[0]
|
||||
let bundleDir: string
|
||||
try {
|
||||
const pickedStat = await stat(picked)
|
||||
bundleDir = pickedStat.isDirectory() ? picked : dirname(picked)
|
||||
} catch {
|
||||
throw new Error('Could not read the selected path.')
|
||||
}
|
||||
|
||||
const manifestPath = join(bundleDir, 'pet.json')
|
||||
let manifestStat: Awaited<ReturnType<typeof stat>>
|
||||
try {
|
||||
manifestStat = await stat(manifestPath)
|
||||
} catch {
|
||||
throw new Error('Bundle is missing pet.json.')
|
||||
}
|
||||
if (!manifestStat.isFile() || manifestStat.size > MAX_MANIFEST_BYTES) {
|
||||
throw new Error('pet.json is invalid.')
|
||||
}
|
||||
if (await isSymlink(manifestPath)) {
|
||||
throw new Error('pet.json must not be a symlink.')
|
||||
}
|
||||
|
||||
let manifest: PetManifest
|
||||
try {
|
||||
const raw = await readFile(manifestPath, 'utf8')
|
||||
// Why: defend against TOCTOU between stat and read — the file could have
|
||||
// grown after the stat check.
|
||||
if (Buffer.byteLength(raw, 'utf8') > MAX_MANIFEST_BYTES) {
|
||||
throw new Error('pet.json exceeded the manifest size limit.')
|
||||
}
|
||||
manifest = PetManifestSchema.parse(JSON.parse(raw))
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid pet.json: ${error instanceof Error ? error.message : 'parse error'}`)
|
||||
}
|
||||
|
||||
// Why: spritesheetPath is bundle-relative. Reject absolute paths and any
|
||||
// resolved path that escapes the bundle directory. Also reject symlinks so
|
||||
// a malicious bundle can't reach outside via a sibling link.
|
||||
if (isAbsolute(manifest.spritesheetPath)) {
|
||||
throw new Error('spritesheetPath must be relative to the bundle.')
|
||||
}
|
||||
const sheetSrc = resolve(bundleDir, manifest.spritesheetPath)
|
||||
const bundleResolved = resolve(bundleDir)
|
||||
if (sheetSrc === bundleResolved) {
|
||||
throw new Error('spritesheetPath must point to a file, not the bundle root.')
|
||||
}
|
||||
const bundleRoot = bundleResolved + sep
|
||||
// Why: NTFS/macOS HFS+ default volumes are case-insensitive — a path like
|
||||
// `BUNDLE\sheet.png` is still inside `bundle\`. Compare lowercased on
|
||||
// Windows so the prefix check isn't bypassed by case differences.
|
||||
const cmp = process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s
|
||||
if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) {
|
||||
throw new Error('spritesheetPath escapes the bundle.')
|
||||
}
|
||||
if (await isSymlink(sheetSrc)) {
|
||||
throw new Error('spritesheet must not be a symlink.')
|
||||
}
|
||||
const sheetClass = classifyFile(sheetSrc)
|
||||
if (!sheetClass || sheetClass.ext === '.svg') {
|
||||
// SVG can't be used as a sprite sheet (no pixel grid).
|
||||
throw new Error('Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.')
|
||||
}
|
||||
let sheetStat: Awaited<ReturnType<typeof stat>>
|
||||
try {
|
||||
sheetStat = await stat(sheetSrc)
|
||||
} catch {
|
||||
throw new Error('Spritesheet file not found.')
|
||||
}
|
||||
if (!sheetStat.isFile()) {
|
||||
throw new Error('Spritesheet path is not a file.')
|
||||
}
|
||||
if (sheetStat.size > MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Spritesheet is too large (${(sheetStat.size / (1024 * 1024)).toFixed(1)} MB).`
|
||||
)
|
||||
}
|
||||
|
||||
let sprite: NonNullable<CustomSidekick['sprite']> | undefined
|
||||
if (manifest.frame) {
|
||||
// Why: only decode the sheet when we need to validate frame layout.
|
||||
// nativeImage may fail on some WebP variants in headless contexts, and
|
||||
// bundles without `frame` render as a static image where dimensions
|
||||
// don't matter.
|
||||
const sheetBuf = await readFile(sheetSrc)
|
||||
// Why: defend against TOCTOU — file may have grown between stat and read.
|
||||
if (sheetBuf.byteLength > MAX_BYTES) {
|
||||
throw new Error('Spritesheet exceeded the size limit.')
|
||||
}
|
||||
const dims = await readSheetDimensions(sheetBuf)
|
||||
if (!dims) {
|
||||
throw new Error('Could not decode the spritesheet image.')
|
||||
}
|
||||
const { width: fw, height: fh } = manifest.frame
|
||||
if (dims.width % fw !== 0 || dims.height % fh !== 0) {
|
||||
throw new Error(
|
||||
`Spritesheet ${dims.width}×${dims.height} is not a clean multiple of frame ${fw}×${fh}.`
|
||||
)
|
||||
}
|
||||
const columns = dims.width / fw
|
||||
const rows = dims.height / fh
|
||||
if (manifest.animations) {
|
||||
for (const [name, anim] of Object.entries(manifest.animations)) {
|
||||
if (anim.row >= rows) {
|
||||
throw new Error(`Animation "${name}" references row ${anim.row} but sheet has ${rows}.`)
|
||||
}
|
||||
if (anim.frames > columns) {
|
||||
throw new Error(
|
||||
`Animation "${name}" has ${anim.frames} frames but sheet only has ${columns} columns.`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (manifest.defaultAnimation && !manifest.animations[manifest.defaultAnimation]) {
|
||||
throw new Error(`defaultAnimation "${manifest.defaultAnimation}" not in animations.`)
|
||||
}
|
||||
}
|
||||
sprite = {
|
||||
frameWidth: fw,
|
||||
frameHeight: fh,
|
||||
columns,
|
||||
rows,
|
||||
sheetWidth: dims.width,
|
||||
sheetHeight: dims.height,
|
||||
fps: manifest.fps ?? 8,
|
||||
defaultAnimation: manifest.defaultAnimation,
|
||||
animations: manifest.animations
|
||||
}
|
||||
}
|
||||
|
||||
// Why: storage id is always a fresh UUID — the manifest's `id` is purely
|
||||
// a display hint. This guards against (a) collisions with bundled
|
||||
// sidekick ids, (b) failing isSafeId, and (c) re-import clobbering an
|
||||
// earlier copy of the same bundle.
|
||||
const id = randomUUID()
|
||||
const root = getSidekicksDir()
|
||||
await mkdir(root, { recursive: true })
|
||||
const destDir = join(root, id)
|
||||
const sheetExt = sheetClass.ext
|
||||
const sheetFileName = `spritesheet${sheetExt}`
|
||||
// Why: stage the bundle into a sibling .tmp directory and atomically rename
|
||||
// into place so destDir only appears once both files are written. Avoids
|
||||
// half-imported bundles if a copy fails midway.
|
||||
const tmpDir = `${destDir}.tmp`
|
||||
try {
|
||||
await rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
||||
await mkdir(tmpDir, { recursive: true })
|
||||
await copyFile(sheetSrc, join(tmpDir, sheetFileName))
|
||||
await copyFile(manifestPath, join(tmpDir, 'pet.json'))
|
||||
await rename(tmpDir, destDir)
|
||||
} catch {
|
||||
await rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
||||
throw new Error('Could not save the pet bundle.')
|
||||
}
|
||||
|
||||
const rawLabel = (manifest.displayName ?? manifest.id ?? basename(bundleDir)).trim()
|
||||
const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Pet bundle'
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
fileName: sheetFileName,
|
||||
mimeType: sheetClass.mimeType,
|
||||
kind: 'bundle',
|
||||
sprite,
|
||||
// Why: carries manifest.fps for detected-frame bundles where `sprite`
|
||||
// is undefined — renderer falls back to this when sprite is absent.
|
||||
...(manifest.fps !== undefined ? { spriteFps: manifest.fps } : {})
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'sidekick:read',
|
||||
async (_event, id: string, fileName: string): Promise<ArrayBuffer | null> => {
|
||||
const filePath = resolveSidekickFile(id, fileName)
|
||||
async (
|
||||
_event,
|
||||
id: string,
|
||||
fileName: string,
|
||||
kind?: 'image' | 'bundle'
|
||||
): Promise<ArrayBuffer | null> => {
|
||||
// Why: validate IPC inputs before any path logic — renderer is not
|
||||
// trusted to send strings of the right shape.
|
||||
let parsed: z.infer<typeof SidekickFileRequestSchema>
|
||||
try {
|
||||
parsed = SidekickFileRequestSchema.parse({ id, fileName, kind })
|
||||
} catch {
|
||||
throw new Error('Invalid sidekick:read arguments')
|
||||
}
|
||||
// Why: missing kind defaults to 'image' for backwards compatibility with
|
||||
// pre-bundle persisted state.
|
||||
const filePath = resolveSidekickFile(parsed.id, parsed.fileName, parsed.kind ?? 'image')
|
||||
if (!filePath) {
|
||||
return null
|
||||
}
|
||||
@@ -150,15 +442,44 @@ export function registerSidekickHandlers(): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('sidekick:delete', async (_event, id: string, fileName: string): Promise<void> => {
|
||||
const filePath = resolveSidekickFile(id, fileName)
|
||||
if (!filePath) {
|
||||
return
|
||||
ipcMain.handle(
|
||||
'sidekick:delete',
|
||||
async (_event, id: string, fileName: string, kind?: 'image' | 'bundle'): Promise<void> => {
|
||||
// Why: validate IPC inputs before any path logic.
|
||||
let parsed: z.infer<typeof SidekickFileRequestSchema>
|
||||
try {
|
||||
parsed = SidekickFileRequestSchema.parse({ id, fileName, kind })
|
||||
} catch {
|
||||
throw new Error('Invalid sidekick:delete arguments')
|
||||
}
|
||||
if (!isSafeId(parsed.id)) {
|
||||
return
|
||||
}
|
||||
if ((parsed.kind ?? 'image') === 'bundle') {
|
||||
// Why: bundle imports own a whole directory. isSafeId already gates id;
|
||||
// we still build the path under the sidekicks root and verify the
|
||||
// prefix before recursive removal as defense in depth.
|
||||
const root = normalize(getSidekicksDir())
|
||||
const target = normalize(join(root, parsed.id))
|
||||
if (!target.startsWith(root + sep)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await rm(target, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
console.warn('[sidekick-overlay] sidekick:delete (bundle) failed', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
const filePath = resolveSidekickFile(parsed.id, parsed.fileName, 'image')
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await rm(filePath, { force: true })
|
||||
} catch (error) {
|
||||
console.warn('[sidekick-overlay] sidekick:delete failed', error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await rm(filePath, { force: true })
|
||||
} catch (error) {
|
||||
console.warn('[sidekick-overlay] sidekick:delete failed', error)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,9 +245,8 @@ describe('telemetry IPC handlers', () => {
|
||||
it('routes banner ✕ through persistBannerAcknowledgeWithoutEmitting without invoking setOptIn', () => {
|
||||
// This is the whole point of the separate channel: the silent-persist
|
||||
// path MUST NOT reach setOptIn, which would derive a `via` and fire
|
||||
// `telemetry_opted_in`. The ✕-as-silent-acknowledge semantics are
|
||||
// explicit: the user did not opt in, they declined to intervene, so
|
||||
// no event transmits.
|
||||
// `telemetry_opted_in`. The client primitive may unlock `app_opened`,
|
||||
// but the acknowledge channel itself must not emit an opt-in event.
|
||||
registerWith({
|
||||
installId: 'x',
|
||||
existedBeforeTelemetryRelease: true,
|
||||
|
||||
@@ -165,13 +165,11 @@ export function registerTelemetryHandlers(store: Store): void {
|
||||
return resolveConsent(storeRef.getSettings())
|
||||
})
|
||||
|
||||
ipcMain.handle('telemetry:acknowledgeBanner', (_event): void => {
|
||||
// Banner ✕ — persist `optedIn = true`, emit nothing. The ✕-as-silent-
|
||||
// acknowledge semantics are explicit: the user did not explicitly opt
|
||||
// in, they declined to intervene, so no event transmits. This outcome
|
||||
// cannot route through `telemetry:setOptIn` because the derivation
|
||||
// above would tag it `first_launch_banner` and fire
|
||||
// `telemetry_opted_in`.
|
||||
ipcMain.handle('telemetry:acknowledgeBanner', (_event): Promise<void> | void => {
|
||||
// Banner ✕ — persist `optedIn = true` without emitting a telemetry opt-in
|
||||
// event. The acknowledge still unlocks `app_opened`, but this outcome
|
||||
// cannot route through `telemetry:setOptIn` because the derivation above
|
||||
// would tag it `first_launch_banner` and fire `telemetry_opted_in`.
|
||||
//
|
||||
// Check storeRef BEFORE consuming a consent-mutation token, mirroring
|
||||
// the setOptIn handler's guard above — see that comment for why
|
||||
@@ -205,7 +203,7 @@ export function registerTelemetryHandlers(store: Store): void {
|
||||
if (!consumeConsentMutationToken()) {
|
||||
return
|
||||
}
|
||||
persistBannerAcknowledgeWithoutEmitting()
|
||||
return persistBannerAcknowledgeWithoutEmitting()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ export function mergeWorktree(
|
||||
isPinned: meta?.isPinned ?? false,
|
||||
sortOrder: meta?.sortOrder ?? 0,
|
||||
lastActivityAt: meta?.lastActivityAt ?? 0,
|
||||
...(meta?.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
|
||||
...(git.isSparse === true
|
||||
? {
|
||||
sparseDirectories: meta?.sparseDirectories,
|
||||
|
||||
@@ -204,8 +204,16 @@ export async function createRemoteWorktree(
|
||||
}
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const now = Date.now()
|
||||
const metaUpdates: Partial<WorktreeMeta> = {
|
||||
lastActivityAt: Date.now(),
|
||||
lastActivityAt: now,
|
||||
// Why: grants the new worktree a short grace window at the top of the
|
||||
// Recent sort. During worktree creation (git fetch + add can take several
|
||||
// seconds) other worktrees get ambient PTY bumps that would otherwise
|
||||
// leave the newly-created one below them; the Recent comparator uses
|
||||
// max(lastActivityAt, createdAt + GRACE_MS) to keep it on top until the
|
||||
// window elapses. See smart-sort.ts `CREATE_GRACE_MS`.
|
||||
createdAt: now,
|
||||
...(shouldSetDisplayName(requestedName, branchName, sanitizedName)
|
||||
? { displayName: requestedName }
|
||||
: {})
|
||||
@@ -434,11 +442,15 @@ export async function createLocalWorktree(
|
||||
}
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const now = Date.now()
|
||||
const metaUpdates: Partial<WorktreeMeta> = {
|
||||
// Stamp activity so the worktree sorts into its final position
|
||||
// immediately — prevents scroll-to-reveal racing with a later
|
||||
// bumpWorktreeActivity that would re-sort the list.
|
||||
lastActivityAt: Date.now(),
|
||||
lastActivityAt: now,
|
||||
// See createRemoteWorktree above: createdAt protects the newly-created
|
||||
// worktree from ambient PTY bumps in other worktrees for CREATE_GRACE_MS.
|
||||
createdAt: now,
|
||||
...(shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
|
||||
? { displayName: effectiveRequestedName }
|
||||
: {}),
|
||||
|
||||
@@ -378,6 +378,136 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stamps lastActivityAt on first discovery so newly-added worktrees sort to the top of Recent', async () => {
|
||||
// Why: a worktree that exists on disk but has no persisted WorktreeMeta
|
||||
// (e.g. a folder repo just added, or a pre-existing worktree in a
|
||||
// newly-added git repo) would otherwise fall back to `lastActivityAt: 0`
|
||||
// and rank dead last in the Recent sort.
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/discovered-wt',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
const stampedMeta = { lastActivityAt: 1_700_000_000_000 }
|
||||
store.setWorktreeMeta.mockReturnValue(stampedMeta)
|
||||
|
||||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||||
id: string
|
||||
lastActivityAt: number
|
||||
}[]
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/discovered-wt',
|
||||
expect.objectContaining({ lastActivityAt: expect.any(Number) })
|
||||
)
|
||||
expect(listed[0]).toMatchObject({
|
||||
id: 'repo-1::/workspace/discovered-wt',
|
||||
lastActivityAt: 1_700_000_000_000
|
||||
})
|
||||
})
|
||||
|
||||
it('does not re-stamp lastActivityAt when a worktree already has persisted meta', async () => {
|
||||
// Why: only the *first* discovery should stamp. Re-stamping on every list
|
||||
// would overwrite real activity and reshuffle the sidebar on refresh.
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/existing-wt',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.getWorktreeMeta.mockReturnValue({
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 42
|
||||
})
|
||||
|
||||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||||
id: string
|
||||
lastActivityAt: number
|
||||
}[]
|
||||
|
||||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(listed[0].lastActivityAt).toBe(42)
|
||||
})
|
||||
|
||||
it('stamps lastActivityAt on first discovery for folder-mode repos', async () => {
|
||||
// Why: folder repos produce a synthetic worktree that flows through the
|
||||
// same list path. Without the stamp, adding a folder puts its card at the
|
||||
// bottom of Recent even though the user just added it.
|
||||
store.getRepos.mockReturnValue([
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
}
|
||||
])
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-1',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
})
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 })
|
||||
|
||||
await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/folder',
|
||||
expect.objectContaining({ lastActivityAt: expect.any(Number) })
|
||||
)
|
||||
})
|
||||
|
||||
it('stamps lastActivityAt on first discovery via worktrees:listAll', async () => {
|
||||
// Why: the stamping logic lives in both worktrees:list and worktrees:listAll.
|
||||
// Without a dedicated test, a regression in the listAll loop would silently
|
||||
// bury newly-discovered worktrees from the multi-repo sidebar view.
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/discovered-wt',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 })
|
||||
|
||||
const listed = (await handlers['worktrees:listAll'](null, undefined)) as {
|
||||
id: string
|
||||
lastActivityAt: number
|
||||
}[]
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/discovered-wt',
|
||||
expect.objectContaining({ lastActivityAt: expect.any(Number) })
|
||||
)
|
||||
expect(listed[0]).toMatchObject({
|
||||
id: 'repo-1::/workspace/discovered-wt',
|
||||
lastActivityAt: 1_700_000_000_000
|
||||
})
|
||||
})
|
||||
|
||||
it('skips past a suffix that already belongs to a PR after an initial branch conflict', async () => {
|
||||
// Why: `gh pr list` is network-bound and previously fired on every single
|
||||
// create, adding 1–3s to the happy path. We now only probe PR conflicts
|
||||
|
||||
@@ -38,6 +38,22 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { killAllProcessesForWorktree } from '../runtime/worktree-teardown'
|
||||
import { getLocalPtyProvider } from './pty'
|
||||
import { removeWorktreeSymlinks } from './worktree-symlinks'
|
||||
import { track } from '../telemetry/client'
|
||||
import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events'
|
||||
|
||||
// Why: worktrees discovered on disk (not created via Orca's UI) have no
|
||||
// persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`.
|
||||
// That makes them sort to the bottom of "Recent" even though the user just
|
||||
// added the repo / folder. Stamp discovery time the first time we see a
|
||||
// worktree so its very existence counts as a recency signal. Subsequent
|
||||
// list calls find the persisted meta and skip the stamp.
|
||||
function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta {
|
||||
const existing = store.getWorktreeMeta(worktreeId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() })
|
||||
}
|
||||
|
||||
export function registerWorktreeHandlers(
|
||||
mainWindow: BrowserWindow,
|
||||
@@ -84,7 +100,7 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
return gitWorktrees.map((gw) => {
|
||||
const worktreeId = `${repo.id}::${gw.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId)
|
||||
return mergeWorktree(repo.id, gw, meta, repo.displayName)
|
||||
})
|
||||
} catch {
|
||||
@@ -126,7 +142,7 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
return gitWorktrees.map((gw) => {
|
||||
const worktreeId = `${repo.id}::${gw.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId)
|
||||
return mergeWorktree(repo.id, gw, meta, repo.displayName)
|
||||
})
|
||||
})
|
||||
@@ -143,11 +159,26 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
|
||||
// Remote repos route all git operations through the relay
|
||||
if (repo.connectionId) {
|
||||
return createRemoteWorktree(args, repo, store, mainWindow)
|
||||
}
|
||||
const result = repo.connectionId
|
||||
? await createRemoteWorktree(args, repo, store, mainWindow)
|
||||
: await createLocalWorktree(args, repo, store, mainWindow, runtime)
|
||||
|
||||
return createLocalWorktree(args, repo, store, mainWindow, runtime)
|
||||
// Why: emit `workspace_created` only after the underlying create has
|
||||
// resolved (the helpers throw on failure, so reaching this line means
|
||||
// git-add succeeded — we deliberately do not also emit a separate
|
||||
// `workspace_initialized`, see telemetry-plan.md§Deferred events).
|
||||
// `from_existing_branch` is true iff the caller specified a non-empty
|
||||
// baseBranch; an unspecified baseBranch means "branch from default
|
||||
// HEAD", which is the not-from-existing-branch case. We never send
|
||||
// the branch name itself.
|
||||
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
|
||||
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
|
||||
track('workspace_created', {
|
||||
source,
|
||||
from_existing_branch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -74,9 +74,17 @@ export class RateLimitService {
|
||||
private inactiveCodexFetching = new Set<string>()
|
||||
private lastInactiveClaudeFetchAt = 0
|
||||
private lastInactiveCodexFetchAt = 0
|
||||
private stateListeners = new Set<(state: RateLimitState) => void>()
|
||||
|
||||
constructor() {}
|
||||
|
||||
onStateChange(listener: (state: RateLimitState) => void): () => void {
|
||||
this.stateListeners.add(listener)
|
||||
return () => {
|
||||
this.stateListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
setCodexHomePathResolver(resolver: () => string | null): void {
|
||||
this.codexHomePathResolver = resolver
|
||||
}
|
||||
@@ -757,9 +765,17 @@ export class RateLimitService {
|
||||
}
|
||||
|
||||
private pushToRenderer(): void {
|
||||
const state = this.getState()
|
||||
for (const listener of this.stateListeners) {
|
||||
try {
|
||||
listener(state)
|
||||
} catch {
|
||||
// ignore — one bad listener must not break the others
|
||||
}
|
||||
}
|
||||
if (!this.mainWindow || this.mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
this.mainWindow.webContents.send('rateLimits:update', this.getState())
|
||||
this.mainWindow.webContents.send('rateLimits:update', state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,6 +793,46 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('clears stale working status after the agent exits and the shell takes over the title', async () => {
|
||||
// Why: regression test for issue #1437 — the mobile worktree-list spinner
|
||||
// kept playing forever because lastAgentStatus was sticky on 'working'
|
||||
// once an agent exited without emitting an idle/agent-shaped final OSC
|
||||
// title. worktree.ps must recompute from the live OSC title each call.
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Codex working',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
||||
const working = await runtime.getWorktreePs()
|
||||
expect(working.worktrees[0].status).toBe('working')
|
||||
|
||||
// Agent exits, shell title takes over — desktop's getWorktreeStatus would
|
||||
// immediately flip back to 'active'. Mobile must do the same.
|
||||
runtime.onPtyData('pty-1', '\x1b]0;bash\x07', 200)
|
||||
const afterExit = await runtime.getWorktreePs()
|
||||
expect(afterExit.worktrees[0].status).toBe('active')
|
||||
})
|
||||
|
||||
it('fails terminal stop closed while the renderer graph is reloading', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
let killed = false
|
||||
@@ -1064,6 +1104,54 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined)
|
||||
})
|
||||
|
||||
it('stamps createdAt alongside lastActivityAt so CLI-created worktrees get the Recent-sort grace window', async () => {
|
||||
// Why: parity with createLocalWorktree / createRemoteWorktree. Without
|
||||
// createdAt, ambient PTY bumps in OTHER worktrees during the few seconds
|
||||
// after creation can push the new worktree below them in Recent sort.
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-grace')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-grace')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: {} })
|
||||
vi.mocked(listWorktrees).mockResolvedValueOnce([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-grace',
|
||||
head: 'def',
|
||||
branch: 'runtime-grace',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const before = Date.now()
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-grace'
|
||||
})
|
||||
const after = Date.now()
|
||||
|
||||
expect(result.worktree.createdAt).toBeDefined()
|
||||
expect(result.worktree.createdAt).toBeGreaterThanOrEqual(before)
|
||||
expect(result.worktree.createdAt).toBeLessThanOrEqual(after)
|
||||
// Both fields must be stamped from the same `now` so the grace-window
|
||||
// math (max(lastActivityAt, createdAt + GRACE_MS)) is well-defined.
|
||||
expect(result.worktree.createdAt).toBe(result.worktree.lastActivityAt)
|
||||
})
|
||||
|
||||
it('skips archive hooks for CLI worktree removal by default', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
|
||||
@@ -134,6 +134,23 @@ import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
import { killAllProcessesForWorktree } from './worktree-teardown'
|
||||
import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits'
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
import type { ClaudeAccountService } from '../claude-accounts/service'
|
||||
import type { CodexAccountService } from '../codex-accounts/service'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types'
|
||||
import type { RateLimitState } from '../../shared/rate-limit-types'
|
||||
|
||||
type RuntimeAccountServices = {
|
||||
claudeAccounts: ClaudeAccountService
|
||||
codexAccounts: CodexAccountService
|
||||
rateLimits: RateLimitService
|
||||
}
|
||||
|
||||
export type AccountsSnapshot = {
|
||||
claude: ClaudeRateLimitAccountsState
|
||||
codex: CodexRateLimitAccountsState
|
||||
rateLimits: RateLimitState
|
||||
}
|
||||
|
||||
type RuntimeStore = {
|
||||
getRepos: Store['getRepos']
|
||||
@@ -167,6 +184,12 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & {
|
||||
tailLinesTotal: number
|
||||
preview: string
|
||||
lastAgentStatus: AgentStatus | null
|
||||
// Why: the most recent OSC title observed on this leaf's PTY data. Used by
|
||||
// worktree.ps so daemon-hosted terminals (no renderer pushing pane titles)
|
||||
// still recompute working/idle from the live title each call instead of
|
||||
// serving a stale `lastAgentStatus` after the agent process exits and the
|
||||
// shell takes over the title — the bug behind issue #1437.
|
||||
lastOscTitle: string | null
|
||||
}
|
||||
|
||||
type RuntimePtyWorktreeRecord = {
|
||||
@@ -351,6 +374,12 @@ export class OrcaRuntimeService {
|
||||
Set<(event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void>
|
||||
>()
|
||||
private subscriptionCleanups = new Map<string, () => void>()
|
||||
// Why: index of subscriptionIds by per-WebSocket connectionId so the
|
||||
// server can sweep all subscriptions for a closing socket without
|
||||
// touching subscriptions on other live sockets that share the same
|
||||
// deviceToken (multi-screen mobile).
|
||||
private subscriptionsByConnection = new Map<string, Set<string>>()
|
||||
private subscriptionConnectionByEntry = new Map<string, string>()
|
||||
// Why: mobile clients subscribe to desktop notifications via
|
||||
// notifications.subscribe. This set enables fan-out — each connected
|
||||
// mobile client gets its own listener, and dispatchMobileNotification
|
||||
@@ -505,6 +534,7 @@ export class OrcaRuntimeService {
|
||||
private fetchInflight = new Map<string, Promise<void>>()
|
||||
private fetchLastCompletedAt = new Map<string, number>()
|
||||
private readonly getLocalProviderFn: (() => IPtyProvider) | null
|
||||
private accountServices: RuntimeAccountServices | null = null
|
||||
|
||||
constructor(
|
||||
store: RuntimeStore | null = null,
|
||||
@@ -632,7 +662,8 @@ export class OrcaRuntimeService {
|
||||
tailTruncated: existing?.ptyId === ptyId ? existing.tailTruncated : false,
|
||||
tailLinesTotal: existing?.ptyId === ptyId ? existing.tailLinesTotal : 0,
|
||||
preview: existing?.ptyId === ptyId ? existing.preview : '',
|
||||
lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null
|
||||
lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null,
|
||||
lastOscTitle: existing?.ptyId === ptyId ? existing.lastOscTitle : null
|
||||
})
|
||||
|
||||
if (leaf.ptyId) {
|
||||
@@ -790,8 +821,21 @@ export class OrcaRuntimeService {
|
||||
leaf.tailLinesTotal += nextTail.newCompleteLines
|
||||
leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine)
|
||||
|
||||
if (agentStatus !== null) {
|
||||
if (oscTitle !== null) {
|
||||
// Why: keep the latest OSC title on the leaf so worktree.ps can
|
||||
// recompute status from the live title each call. Without this,
|
||||
// daemon-hosted terminals (no renderer pushing pane titles) had no
|
||||
// way to clear a stale 'working' status after the agent exited and
|
||||
// the shell took over the title — the stuck-spinner bug in #1437.
|
||||
leaf.lastOscTitle = oscTitle
|
||||
const prevStatus = leaf.lastAgentStatus
|
||||
// Why: when a new OSC title doesn't classify as an agent state (e.g.
|
||||
// bare shell title after the agent exits), clear lastAgentStatus so
|
||||
// it is no longer sticky. Tui-idle waiters that needed the previous
|
||||
// 'idle' transition were already resolved at the moment of the
|
||||
// transition below; only fresh waiters registered after the agent
|
||||
// exits would observe the cleared value, and they correctly fall
|
||||
// back to title-based detection / polling.
|
||||
leaf.lastAgentStatus = agentStatus
|
||||
// Why: resolve tui-idle on any transition TO idle (not just working→idle).
|
||||
// Claude Code may skip "working" entirely on fast tasks, going null→idle,
|
||||
@@ -986,12 +1030,15 @@ export class OrcaRuntimeService {
|
||||
return
|
||||
}
|
||||
const status = detectAgentStatusFromTitle(title)
|
||||
if (status === null) {
|
||||
return
|
||||
}
|
||||
for (const leaf of this.leaves.values()) {
|
||||
if (leaf.ptyId === ptyId) {
|
||||
leaf.lastAgentStatus = status
|
||||
// Why: seed lastOscTitle even when the seeded title doesn't classify
|
||||
// as an agent state, so worktree.ps recomputes status from the live
|
||||
// title rather than treating the leaf as agentless.
|
||||
leaf.lastOscTitle = title
|
||||
if (status !== null) {
|
||||
leaf.lastAgentStatus = status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1107,7 +1154,11 @@ export class OrcaRuntimeService {
|
||||
return { ptyId: leaf.ptyId }
|
||||
}
|
||||
|
||||
registerSubscriptionCleanup(subscriptionId: string, cleanup: () => void): void {
|
||||
registerSubscriptionCleanup(
|
||||
subscriptionId: string,
|
||||
cleanup: () => void,
|
||||
connectionId?: string
|
||||
): void {
|
||||
// Why: mobile clients reconnect frequently (phone lock, network switch).
|
||||
// The RPC client re-sends terminal.subscribe on reconnect, creating a new
|
||||
// handler before the old one is cleaned up. Without this, the old data
|
||||
@@ -1115,18 +1166,57 @@ export class OrcaRuntimeService {
|
||||
const existing = this.subscriptionCleanups.get(subscriptionId)
|
||||
if (existing) {
|
||||
existing()
|
||||
// Why: existing() already evicts itself from the per-connection index
|
||||
// via cleanupSubscription, so no extra bookkeeping is needed here.
|
||||
}
|
||||
this.subscriptionCleanups.set(subscriptionId, cleanup)
|
||||
if (connectionId) {
|
||||
let set = this.subscriptionsByConnection.get(connectionId)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.subscriptionsByConnection.set(connectionId, set)
|
||||
}
|
||||
set.add(subscriptionId)
|
||||
this.subscriptionConnectionByEntry.set(subscriptionId, connectionId)
|
||||
}
|
||||
}
|
||||
|
||||
cleanupSubscription(subscriptionId: string): void {
|
||||
const cleanup = this.subscriptionCleanups.get(subscriptionId)
|
||||
if (cleanup) {
|
||||
this.subscriptionCleanups.delete(subscriptionId)
|
||||
const connectionId = this.subscriptionConnectionByEntry.get(subscriptionId)
|
||||
if (connectionId) {
|
||||
this.subscriptionConnectionByEntry.delete(subscriptionId)
|
||||
const set = this.subscriptionsByConnection.get(connectionId)
|
||||
if (set) {
|
||||
set.delete(subscriptionId)
|
||||
if (set.size === 0) {
|
||||
this.subscriptionsByConnection.delete(connectionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: invoked from the WebSocket transport's on-close hook so streaming
|
||||
// listeners registered for this exact socket get torn down even when other
|
||||
// sockets sharing the same deviceToken are still alive (multi-screen
|
||||
// mobile). Without this sweep, listeners leak across every reconnect.
|
||||
cleanupSubscriptionsForConnection(connectionId: string): void {
|
||||
const set = this.subscriptionsByConnection.get(connectionId)
|
||||
if (!set) {
|
||||
return
|
||||
}
|
||||
// Why: snapshot the ids before iterating because cleanupSubscription
|
||||
// mutates both the set and the index map.
|
||||
const ids = Array.from(set)
|
||||
for (const id of ids) {
|
||||
this.cleanupSubscription(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: mobile clients subscribe via notifications.subscribe streaming RPC.
|
||||
// Each subscriber gets its own listener. Returns an unsubscribe function
|
||||
// that the subscription cleanup mechanism calls on disconnect.
|
||||
@@ -1147,6 +1237,73 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Account Services (mobile RPC bridge) ─────────────────────
|
||||
|
||||
setAccountServices(services: RuntimeAccountServices): void {
|
||||
this.accountServices = services
|
||||
}
|
||||
|
||||
private requireAccountServices(): RuntimeAccountServices {
|
||||
if (!this.accountServices) {
|
||||
throw new Error('Account services are not configured on this runtime')
|
||||
}
|
||||
return this.accountServices
|
||||
}
|
||||
|
||||
getAccountsSnapshot(): AccountsSnapshot {
|
||||
const { claudeAccounts, codexAccounts, rateLimits } = this.requireAccountServices()
|
||||
return {
|
||||
claude: claudeAccounts.listAccounts(),
|
||||
codex: codexAccounts.listAccounts(),
|
||||
rateLimits: rateLimits.getState()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: RateLimitService polls only when the Electron window is visible AND
|
||||
// focused, and the inactive-account caches fill lazily when the user opens
|
||||
// the desktop AccountsPane. Mobile has neither trigger, so without this the
|
||||
// phone shows 0% / "—" against a backgrounded desktop. Errors swallowed
|
||||
// because partial usage is still useful for the rest of the snapshot.
|
||||
async refreshAccountsForMobile(): Promise<void> {
|
||||
const { rateLimits } = this.requireAccountServices()
|
||||
await Promise.allSettled([
|
||||
rateLimits.refresh(),
|
||||
rateLimits.fetchInactiveClaudeAccountsOnOpen(),
|
||||
rateLimits.fetchInactiveCodexAccountsOnOpen()
|
||||
])
|
||||
}
|
||||
|
||||
selectClaudeAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
|
||||
return this.requireAccountServices().claudeAccounts.selectAccount(accountId)
|
||||
}
|
||||
|
||||
selectCodexAccount(accountId: string | null): Promise<CodexRateLimitAccountsState> {
|
||||
return this.requireAccountServices().codexAccounts.selectAccount(accountId)
|
||||
}
|
||||
|
||||
removeClaudeAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
|
||||
return this.requireAccountServices().claudeAccounts.removeAccount(accountId)
|
||||
}
|
||||
|
||||
removeCodexAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
|
||||
return this.requireAccountServices().codexAccounts.removeAccount(accountId)
|
||||
}
|
||||
|
||||
// Why: rate-limit polling fires every 5 minutes and on account switch.
|
||||
// Mobile clients subscribe to receive a fresh AccountsSnapshot whenever
|
||||
// RateLimitService pushes new usage data, mirroring the existing
|
||||
// `rateLimits:update` IPC channel desktop already uses.
|
||||
onAccountsChanged(listener: (snapshot: AccountsSnapshot) => void): () => void {
|
||||
const services = this.requireAccountServices()
|
||||
return services.rateLimits.onStateChange(() => {
|
||||
listener({
|
||||
claude: services.claudeAccounts.listAccounts(),
|
||||
codex: services.codexAccounts.listAccounts(),
|
||||
rateLimits: services.rateLimits.getState()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Mobile Fit Override Management ─────────────────────────
|
||||
|
||||
resizeForClient(
|
||||
@@ -2810,8 +2967,14 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const now = Date.now()
|
||||
const meta = this.store.setWorktreeMeta(worktreeId, {
|
||||
lastActivityAt: Date.now(),
|
||||
lastActivityAt: now,
|
||||
// See createRemoteWorktree: createdAt grants the new worktree a grace
|
||||
// window in Recent sort so ambient PTY bumps in OTHER worktrees can't
|
||||
// push it down before the user has had a chance to notice it. Smart-sort
|
||||
// uses max(lastActivityAt, createdAt + CREATE_GRACE_MS).
|
||||
createdAt: now,
|
||||
...(shouldSetDisplayName(requestedName, branchName, sanitizedName)
|
||||
? { displayName: requestedName }
|
||||
: {}),
|
||||
@@ -5695,7 +5858,14 @@ function getLeafWorktreeStatus(
|
||||
leaf: RuntimeLeafRecord,
|
||||
tabTitle: string | null
|
||||
): RuntimeWorktreeStatus {
|
||||
const detected = leaf.lastAgentStatus ?? detectAgentStatusFromTitle(leaf.title ?? tabTitle ?? '')
|
||||
// Why: recompute from the live title each call so worktree.ps mirrors what
|
||||
// the desktop sidebar's getWorktreeStatus does (no sticky state). Prefer
|
||||
// the runtime-tracked OSC title (covers daemon-hosted terminals) over the
|
||||
// renderer-pushed leaf.title and the tab title. Falling back to
|
||||
// lastAgentStatus only when no title is available preserves a sensible
|
||||
// signal for very fresh leaves before any title has been observed.
|
||||
const liveTitle = leaf.lastOscTitle ?? leaf.title ?? tabTitle ?? ''
|
||||
const detected = liveTitle ? detectAgentStatusFromTitle(liveTitle) : leaf.lastAgentStatus
|
||||
if (detected === 'permission') {
|
||||
return 'permission'
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ export type RpcContext = {
|
||||
// runtime-rpc transport (direct in-process callers don't need it).
|
||||
// See design doc §3.1 counter-lifecycle.
|
||||
signal?: AbortSignal
|
||||
// Why: streaming handlers (notifications/accounts/terminal subscribe)
|
||||
// register cleanup callbacks against the runtime so reconnects don't leak
|
||||
// listeners. Keying those cleanups by per-WebSocket connectionId lets the
|
||||
// server reap all subscriptions for a closing socket, even when other
|
||||
// sockets for the same deviceToken stay alive (multi-screen mobile).
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
export type RpcHandler<TParams> = (params: TParams, ctx: RpcContext) => Promise<unknown> | unknown
|
||||
|
||||
@@ -74,7 +74,11 @@ export class RpcDispatcher {
|
||||
// Why: streaming dispatch sends multiple responses through the reply callback
|
||||
// instead of returning a single Promise. This enables terminal.subscribe and
|
||||
// other subscription-style methods that push data over time.
|
||||
async dispatchStreaming(request: RpcRequest, reply: (response: string) => void): Promise<void> {
|
||||
async dispatchStreaming(
|
||||
request: RpcRequest,
|
||||
reply: (response: string) => void,
|
||||
options?: { connectionId?: string }
|
||||
): Promise<void> {
|
||||
const meta = this.meta()
|
||||
const method = this.registry.get(request.method)
|
||||
if (!method) {
|
||||
@@ -94,7 +98,10 @@ export class RpcDispatcher {
|
||||
|
||||
if (!isStreamingMethod(method)) {
|
||||
try {
|
||||
const result = await method.handler(parsedParams.value, { runtime: this.runtime })
|
||||
const result = await method.handler(parsedParams.value, {
|
||||
runtime: this.runtime,
|
||||
connectionId: options?.connectionId
|
||||
})
|
||||
reply(JSON.stringify(successResponse(request.id, meta, result)))
|
||||
} catch (error) {
|
||||
reply(JSON.stringify(this.mapError(request, meta, error)))
|
||||
@@ -109,7 +116,11 @@ export class RpcDispatcher {
|
||||
}
|
||||
|
||||
try {
|
||||
await method.handler(parsedParams.value, { runtime: this.runtime }, emit)
|
||||
await method.handler(
|
||||
parsedParams.value,
|
||||
{ runtime: this.runtime, connectionId: options?.connectionId },
|
||||
emit
|
||||
)
|
||||
} catch (error) {
|
||||
reply(JSON.stringify(this.mapError(request, meta, error)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
|
||||
|
||||
// Why: monotonically increasing per-process counter avoids the Date.now()
|
||||
// collision that fired when two near-simultaneous accounts.subscribe calls
|
||||
// collided on the same millisecond and one evicted the other through
|
||||
// registerSubscriptionCleanup's existing-key eviction path.
|
||||
let accountsSubscriptionSeq = 0
|
||||
|
||||
const SelectAccountParams = z.object({
|
||||
accountId: z
|
||||
.union([z.string().min(1, 'Missing accountId'), z.null()])
|
||||
.transform((v) => (v === null ? null : v))
|
||||
})
|
||||
|
||||
const RemoveAccountParams = z.object({
|
||||
accountId: z.string().min(1, 'Missing accountId')
|
||||
})
|
||||
|
||||
const AccountsUnsubscribeParams = z.object({
|
||||
subscriptionId: z
|
||||
.unknown()
|
||||
.transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
|
||||
.pipe(z.string().min(1, 'Missing subscriptionId'))
|
||||
})
|
||||
|
||||
// Why: bridges the desktop ClaudeAccountService / CodexAccountService /
|
||||
// RateLimitService into the mobile WebSocket RPC. Read + switch + remove
|
||||
// only — interactive add/re-auth flows spawn `claude login` / `codex login`
|
||||
// PTYs that need a desktop browser, so they intentionally remain
|
||||
// desktop-only. See plan in spec doc for issue #1438.
|
||||
export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'accounts.list',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }) => {
|
||||
// Why: ensure the snapshot reflects the latest provider state before
|
||||
// returning. Desktop polling pauses when the window is unfocused and
|
||||
// inactive-account caches only fill on AccountsPane open, so without
|
||||
// this the mobile UI would render stale nulls / zeroes.
|
||||
await runtime.refreshAccountsForMobile()
|
||||
return runtime.getAccountsSnapshot()
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'accounts.selectClaude',
|
||||
params: SelectAccountParams,
|
||||
handler: async (params, { runtime }) => runtime.selectClaudeAccount(params.accountId)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'accounts.selectCodex',
|
||||
params: SelectAccountParams,
|
||||
handler: async (params, { runtime }) => runtime.selectCodexAccount(params.accountId)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'accounts.removeClaude',
|
||||
params: RemoveAccountParams,
|
||||
handler: async (params, { runtime }) => runtime.removeClaudeAccount(params.accountId)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'accounts.removeCodex',
|
||||
params: RemoveAccountParams,
|
||||
handler: async (params, { runtime }) => runtime.removeCodexAccount(params.accountId)
|
||||
}),
|
||||
// Why: streaming counterpart so mobile usage bars refresh in place when the
|
||||
// desktop's 5-minute rate-limit poll completes or when the user switches
|
||||
// accounts on either side. Mirrors the notifications.subscribe pattern.
|
||||
defineStreamingMethod({
|
||||
name: 'accounts.subscribe',
|
||||
params: null,
|
||||
handler: async (_params, { runtime, connectionId }, emit) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
const unsubscribe = runtime.onAccountsChanged((snapshot) => {
|
||||
emit({ type: 'snapshot', snapshot })
|
||||
})
|
||||
|
||||
// Why: scope the id by connectionId so two sockets from the same
|
||||
// device (host + accounts screen) cannot evict each other through
|
||||
// registerSubscriptionCleanup's "existing key" branch, and append a
|
||||
// per-process counter so two concurrent subscribes on the same
|
||||
// socket also can't collide.
|
||||
const seq = ++accountsSubscriptionSeq
|
||||
const subscriptionId = `accounts-${connectionId ?? 'inproc'}-${seq}`
|
||||
runtime.registerSubscriptionCleanup(
|
||||
subscriptionId,
|
||||
() => {
|
||||
unsubscribe()
|
||||
emit({ type: 'end' })
|
||||
resolve()
|
||||
},
|
||||
connectionId
|
||||
)
|
||||
|
||||
// Why: emit the current snapshot synchronously so the phone has
|
||||
// something to render immediately, then kick a forced refresh that
|
||||
// will broadcast a fresh snapshot through the listener once each
|
||||
// provider fetch completes.
|
||||
emit({ type: 'ready', subscriptionId, snapshot: runtime.getAccountsSnapshot() })
|
||||
void runtime.refreshAccountsForMobile()
|
||||
})
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'accounts.unsubscribe',
|
||||
params: AccountsUnsubscribeParams,
|
||||
handler: async (params, { runtime }) => {
|
||||
runtime.cleanupSubscription(params.subscriptionId)
|
||||
return { unsubscribed: true }
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -8,6 +8,7 @@ import { BROWSER_EXTRA_METHODS } from './browser-extras'
|
||||
import { ORCHESTRATION_METHODS } from './orchestration'
|
||||
import { NOTIFICATION_METHODS } from './notifications'
|
||||
import { STATS_METHODS } from './stats'
|
||||
import { ACCOUNT_METHODS } from './accounts'
|
||||
|
||||
// Why: a flat manifest keeps registration order explicit and provides one
|
||||
// grep-point for "what methods does the RPC server expose?" — useful when
|
||||
@@ -21,5 +22,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...BROWSER_EXTRA_METHODS,
|
||||
...ORCHESTRATION_METHODS,
|
||||
...NOTIFICATION_METHODS,
|
||||
...STATS_METHODS
|
||||
...STATS_METHODS,
|
||||
...ACCOUNT_METHODS
|
||||
]
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
import { defineStreamingMethod, defineMethod, type RpcAnyMethod } from '../core'
|
||||
|
||||
// Why: monotonically increasing per-process counter eliminates the
|
||||
// Date.now() collision that could fire when two near-simultaneous
|
||||
// notifications.subscribe calls landed on the same millisecond.
|
||||
let notificationsSubscriptionSeq = 0
|
||||
|
||||
const NotificationUnsubscribeParams = z.object({
|
||||
subscriptionId: z
|
||||
.unknown()
|
||||
@@ -16,18 +21,25 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [
|
||||
defineStreamingMethod({
|
||||
name: 'notifications.subscribe',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }, emit) => {
|
||||
handler: async (_params, { runtime, connectionId }, emit) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
const unsubscribe = runtime.onNotificationDispatched((event) => {
|
||||
emit({ type: 'notification', ...event })
|
||||
})
|
||||
|
||||
const subscriptionId = `notifications-${Date.now()}`
|
||||
runtime.registerSubscriptionCleanup(subscriptionId, () => {
|
||||
unsubscribe()
|
||||
emit({ type: 'end' })
|
||||
resolve()
|
||||
})
|
||||
// Why: scope by per-ws connectionId + per-process counter so
|
||||
// concurrent subscribes never collide on the cleanup map.
|
||||
const seq = ++notificationsSubscriptionSeq
|
||||
const subscriptionId = `notifications-${connectionId ?? 'inproc'}-${seq}`
|
||||
runtime.registerSubscriptionCleanup(
|
||||
subscriptionId,
|
||||
() => {
|
||||
unsubscribe()
|
||||
emit({ type: 'end' })
|
||||
resolve()
|
||||
},
|
||||
connectionId
|
||||
)
|
||||
|
||||
emit({ type: 'ready', subscriptionId })
|
||||
})
|
||||
|
||||
@@ -28,7 +28,9 @@ export class WebSocketTransport implements RpcTransport {
|
||||
private messageHandler:
|
||||
| ((msg: string, reply: (response: string) => void, ws: WebSocket) => void)
|
||||
| null = null
|
||||
private connectionCloseHandler: ((clientId: string) => void) | null = null
|
||||
private connectionCloseHandler:
|
||||
| ((clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void)
|
||||
| null = null
|
||||
// Why: maps each WebSocket to the clientId (deviceToken) that authenticated it,
|
||||
// so ws.on('close') can notify the runtime which mobile client disconnected.
|
||||
private wsClientIds = new Map<WebSocket, string>()
|
||||
@@ -46,7 +48,15 @@ export class WebSocketTransport implements RpcTransport {
|
||||
this.messageHandler = handler
|
||||
}
|
||||
|
||||
onConnectionClose(handler: (clientId: string) => void): void {
|
||||
// Why: handlers receive the closing `ws` so per-connection state can be
|
||||
// targeted exactly (one paired device may hold multiple concurrent sockets,
|
||||
// e.g. host screen + accounts screen). `hasOtherConnections` tells the
|
||||
// runtime whether other sockets for the same deviceToken are still alive,
|
||||
// so client-scoped teardown (mobile-fit overrides, etc.) only fires on the
|
||||
// last disconnect.
|
||||
onConnectionClose(
|
||||
handler: (clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void
|
||||
): void {
|
||||
this.connectionCloseHandler = handler
|
||||
}
|
||||
|
||||
@@ -178,7 +188,13 @@ export class WebSocketTransport implements RpcTransport {
|
||||
const clientId = this.wsClientIds.get(ws)
|
||||
this.wsClientIds.delete(ws)
|
||||
if (clientId) {
|
||||
this.connectionCloseHandler?.(clientId)
|
||||
// Why: a paired device may have multiple concurrent sockets open
|
||||
// (e.g. one per app screen). Per-client teardown must only fire when
|
||||
// the last socket for this token closes — otherwise closing the
|
||||
// accounts-screen socket would clobber the host-screen socket's
|
||||
// state and strand it in a non-functional state until re-paired.
|
||||
const hasOtherConnections = Array.from(this.wsClientIds.values()).includes(clientId)
|
||||
this.connectionCloseHandler?.(clientId, ws, hasOtherConnections)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -88,6 +88,10 @@ export class OrcaRuntimeRpcServer {
|
||||
// Why: each WebSocket connection has its own E2EE channel that manages the
|
||||
// handshake and encrypt/decrypt lifecycle. Keyed by WebSocket instance.
|
||||
private e2eeChannels = new Map<WebSocket, E2EEChannel>()
|
||||
// Why: stable per-WebSocket id used as the cleanup key for streaming
|
||||
// subscriptions, so the server can reap a closing socket's subscriptions
|
||||
// without affecting other live sockets that share the same deviceToken.
|
||||
private wsConnectionIds = new Map<WebSocket, string>()
|
||||
// Why: separate from Node's server.maxConnections because we need to count
|
||||
// only long-running dispatches, not every in-flight short RPC. See §3.1 +
|
||||
// §7 risk #2.
|
||||
@@ -220,6 +224,11 @@ export class OrcaRuntimeRpcServer {
|
||||
wsTransport.onMessage((msg, _reply, ws) => {
|
||||
let channel = this.e2eeChannels.get(ws)
|
||||
if (!channel) {
|
||||
// Why: stable per-ws id used as the cleanup-index key for
|
||||
// streaming subscriptions, so the server can reap them exactly
|
||||
// when this socket closes (without affecting other live sockets
|
||||
// that share the same deviceToken).
|
||||
this.wsConnectionIds.set(ws, randomBytes(8).toString('hex'))
|
||||
channel = new E2EEChannel(ws, {
|
||||
serverSecretKey: this.e2eeKeypair!.secretKey,
|
||||
validateToken: (token) => this.deviceRegistry?.validateToken(token) != null,
|
||||
@@ -251,16 +260,28 @@ export class OrcaRuntimeRpcServer {
|
||||
|
||||
// Why: when a mobile client disconnects, the runtime must clean up
|
||||
// connection-scoped state like mobile-fit overrides and the E2EE
|
||||
// channel to prevent orphaned state.
|
||||
wsTransport.onConnectionClose((clientId) => {
|
||||
for (const [ws, channel] of this.e2eeChannels) {
|
||||
if (channel.deviceToken === clientId) {
|
||||
channel.destroy()
|
||||
this.e2eeChannels.delete(ws)
|
||||
break
|
||||
}
|
||||
// channel to prevent orphaned state. A single paired device can hold
|
||||
// multiple concurrent sockets (host screen + accounts screen, etc.),
|
||||
// so destroy the channel for THIS exact ws and skip the per-client
|
||||
// teardown when other sockets for the same token are still alive.
|
||||
wsTransport.onConnectionClose((clientId, ws, hasOtherConnections) => {
|
||||
// Why: sweep streaming subscriptions for THIS ws regardless of
|
||||
// hasOtherConnections, so per-ws listeners (notifications,
|
||||
// accounts, terminal) don't leak across reconnects. This is
|
||||
// independent of the deviceToken-scoped onClientDisconnected.
|
||||
const connectionId = this.wsConnectionIds.get(ws)
|
||||
if (connectionId) {
|
||||
this.runtime.cleanupSubscriptionsForConnection(connectionId)
|
||||
this.wsConnectionIds.delete(ws)
|
||||
}
|
||||
const channel = this.e2eeChannels.get(ws)
|
||||
if (channel) {
|
||||
channel.destroy()
|
||||
this.e2eeChannels.delete(ws)
|
||||
}
|
||||
if (!hasOtherConnections) {
|
||||
this.runtime.onClientDisconnected(clientId)
|
||||
}
|
||||
this.runtime.onClientDisconnected(clientId)
|
||||
})
|
||||
|
||||
await wsTransport.start()
|
||||
@@ -429,7 +450,8 @@ export class OrcaRuntimeRpcServer {
|
||||
wsTransport.setClientId(ws, token)
|
||||
}
|
||||
|
||||
await this.dispatcher.dispatchStreaming(request, reply)
|
||||
const connectionId = ws ? this.wsConnectionIds.get(ws) : undefined
|
||||
await this.dispatcher.dispatchStreaming(request, reply, { connectionId })
|
||||
}
|
||||
|
||||
private buildError(id: string, code: string, message: string): RpcResponse {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { classifyError } from './classify-error'
|
||||
|
||||
describe('classifyError', () => {
|
||||
it('classifies ENOENT as binary_not_found', () => {
|
||||
expect(classifyError(new Error('spawn zsh ENOENT'))).toEqual({
|
||||
error_class: 'binary_not_found'
|
||||
})
|
||||
expect(classifyError(Object.assign(new Error('missing'), { code: 'ENOENT' }))).toEqual({
|
||||
error_class: 'binary_not_found'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps shell-output-shaped not-found messages unknown', () => {
|
||||
expect(classifyError(new Error('command not found: claude'))).toEqual({
|
||||
error_class: 'unknown'
|
||||
})
|
||||
expect(classifyError(new Error('workspace not found'))).toEqual({
|
||||
error_class: 'unknown'
|
||||
})
|
||||
expect(
|
||||
classifyError(
|
||||
Object.assign(new Error('socket missing'), { code: 'ENOENT', syscall: 'connect' })
|
||||
)
|
||||
).toEqual({ error_class: 'unknown' })
|
||||
})
|
||||
|
||||
it('returns unknown for null/undefined input', () => {
|
||||
expect(classifyError(null)).toEqual({ error_class: 'unknown' })
|
||||
expect(classifyError(undefined)).toEqual({ error_class: 'unknown' })
|
||||
})
|
||||
|
||||
it('returns unknown for an unrecognized message', () => {
|
||||
expect(classifyError(new Error('some unique failure mode we do not know'))).toEqual({
|
||||
error_class: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns unknown for non-Error throws', () => {
|
||||
expect(classifyError('a bare string')).toEqual({ error_class: 'unknown' })
|
||||
expect(classifyError({ random: 'object' })).toEqual({ error_class: 'unknown' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
// Best-effort classifier from a thrown value to the closed `error_class`
|
||||
// enum on `agent_error`. The wire never carries a raw error message or stack
|
||||
// — every transmitted error is bucketed into one of the two enum members
|
||||
// (`binary_not_found` for ENOENT-shaped failures, `unknown` for everything
|
||||
// else). A non-zero `unknown` slice on the dashboard is the trigger to add a
|
||||
// new enum value alongside the call site that would emit it.
|
||||
|
||||
import type { ErrorClass } from '../../shared/telemetry-events'
|
||||
|
||||
export type ClassifiedError = {
|
||||
error_class: ErrorClass
|
||||
}
|
||||
|
||||
export function classifyError(err: unknown): ClassifiedError {
|
||||
if (err === null || err === undefined) {
|
||||
return { error_class: 'unknown' }
|
||||
}
|
||||
|
||||
const code =
|
||||
typeof err === 'object' && err !== null && 'code' in err
|
||||
? (err as { code?: unknown }).code
|
||||
: undefined
|
||||
const syscall =
|
||||
typeof err === 'object' && err !== null && 'syscall' in err
|
||||
? (err as { syscall?: unknown }).syscall
|
||||
: undefined
|
||||
const message =
|
||||
typeof err === 'object' && err !== null && 'message' in err
|
||||
? (err as { message?: unknown }).message
|
||||
: undefined
|
||||
|
||||
if (
|
||||
code === 'ENOENT' &&
|
||||
(typeof syscall !== 'string' || syscall.toLowerCase().startsWith('spawn'))
|
||||
) {
|
||||
return { error_class: 'binary_not_found' }
|
||||
}
|
||||
|
||||
if (typeof message === 'string' && /\bspawn\b/i.test(message) && /\benoent\b/i.test(message)) {
|
||||
return { error_class: 'binary_not_found' }
|
||||
}
|
||||
|
||||
return { error_class: 'unknown' }
|
||||
}
|
||||
@@ -26,10 +26,13 @@ import {
|
||||
_setPostHogClientForTests,
|
||||
_setShuttingDownForTests,
|
||||
_setStoreForTests,
|
||||
_resetFirstAppOpenedFiredForTests,
|
||||
persistBannerAcknowledgeWithoutEmitting,
|
||||
setOptIn,
|
||||
shouldOptOutSdkAtInit,
|
||||
shutdownTelemetry,
|
||||
track
|
||||
track,
|
||||
trackAppOpenedOnce
|
||||
} from './client'
|
||||
|
||||
// Minimal mock of the PostHog client surface the wrapper actually calls.
|
||||
@@ -170,12 +173,14 @@ describe('track()', () => {
|
||||
_setStoreForTests(store)
|
||||
_setShuttingDownForTests(false)
|
||||
_enableTransportForTests(true)
|
||||
_resetFirstAppOpenedFiredForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
_enableTransportForTests(false)
|
||||
_setPostHogClientForTests(null)
|
||||
_setCommonPropsForTests(null)
|
||||
_setStoreForTests(null)
|
||||
_resetFirstAppOpenedFiredForTests()
|
||||
vi.restoreAllMocks()
|
||||
restoreConsentEnv(envStash)
|
||||
})
|
||||
@@ -270,12 +275,19 @@ describe('track()', () => {
|
||||
it('drops invalid events before calling capture', () => {
|
||||
// Raw error strings on agent_error are rejected by `.strict()`.
|
||||
track('agent_error', {
|
||||
error_class: 'auth_expired',
|
||||
error_class: 'unknown',
|
||||
agent_kind: 'claude-code',
|
||||
error_message: 'leaked message' // rejected by .strict()
|
||||
} as never)
|
||||
expect(mock.capture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('trackAppOpenedOnce emits app_opened at most once per session', () => {
|
||||
trackAppOpenedOnce()
|
||||
trackAppOpenedOnce()
|
||||
expect(mock.capture).toHaveBeenCalledTimes(1)
|
||||
expect(mock.capture.mock.calls[0]![0].event).toBe('app_opened')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setOptIn()', () => {
|
||||
@@ -301,12 +313,14 @@ describe('setOptIn()', () => {
|
||||
_setStoreForTests(store)
|
||||
_setShuttingDownForTests(false)
|
||||
_enableTransportForTests(true)
|
||||
_resetFirstAppOpenedFiredForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
_enableTransportForTests(false)
|
||||
_setPostHogClientForTests(null)
|
||||
_setCommonPropsForTests(null)
|
||||
_setStoreForTests(null)
|
||||
_resetFirstAppOpenedFiredForTests()
|
||||
vi.restoreAllMocks()
|
||||
restoreConsentEnv(envStash)
|
||||
})
|
||||
@@ -333,15 +347,97 @@ describe('setOptIn()', () => {
|
||||
expect(order).toEqual(['capture called', 'sdk enqueue', 'optOut'])
|
||||
})
|
||||
|
||||
it('fires telemetry_opted_in AFTER posthog.optIn()', async () => {
|
||||
it('fires telemetry_opted_in after posthog.optIn without app_opened for settings opt-in', async () => {
|
||||
// Flip settings to currently-opted-out so the flip to true exercises
|
||||
// the opt-in branch cleanly.
|
||||
// the opt-in branch cleanly. This is not the pending-banner path, so
|
||||
// it must not replay the once-per-session app_opened event.
|
||||
settings.telemetry!.optedIn = false
|
||||
const order: string[] = []
|
||||
mock.optIn.mockImplementation(async () => order.push('optIn'))
|
||||
mock.capture.mockImplementation(() => order.push('capture'))
|
||||
mock.capture.mockImplementation((message: { event?: string }) => {
|
||||
order.push(`capture:${message.event}`)
|
||||
})
|
||||
await setOptIn('settings', true)
|
||||
expect(order).toEqual(['optIn', 'capture'])
|
||||
expect(order).toEqual(['optIn', 'capture:telemetry_opted_in'])
|
||||
})
|
||||
|
||||
it('console-mirrors telemetry_opted_in when no PostHog client is initialized', async () => {
|
||||
settings.telemetry!.optedIn = false
|
||||
_setPostHogClientForTests(null)
|
||||
_enableTransportForTests(false)
|
||||
|
||||
await setOptIn('settings', true)
|
||||
|
||||
expect(console.debug).toHaveBeenCalledWith('[telemetry]', 'telemetry_opted_in', {
|
||||
via: 'settings'
|
||||
})
|
||||
})
|
||||
|
||||
it('fires app_opened once after pending-banner opt-in enables the SDK', async () => {
|
||||
settings.telemetry = {
|
||||
optedIn: null,
|
||||
installId: BASE_COMMON.install_id,
|
||||
existedBeforeTelemetryRelease: true
|
||||
}
|
||||
const order: string[] = []
|
||||
mock.optIn.mockImplementation(async () => order.push('optIn'))
|
||||
mock.capture.mockImplementation((message: { event?: string }) => {
|
||||
order.push(`capture:${message.event}`)
|
||||
})
|
||||
|
||||
await setOptIn('settings', true)
|
||||
|
||||
expect(order).toEqual(['optIn', 'capture:app_opened', 'capture:telemetry_opted_in'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistBannerAcknowledgeWithoutEmitting()', () => {
|
||||
let mock: MockPostHog
|
||||
let store: Store
|
||||
let settings: GlobalSettings
|
||||
let envStash: Record<string, string | undefined>
|
||||
|
||||
beforeEach(() => {
|
||||
envStash = stashAndClearConsentEnv()
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
resetBurstCapsForSession()
|
||||
mock = makeMockPostHog()
|
||||
settings = makeFakeSettings({
|
||||
optedIn: null,
|
||||
installId: BASE_COMMON.install_id,
|
||||
existedBeforeTelemetryRelease: true
|
||||
})
|
||||
store = makeFakeStore(settings)
|
||||
_setPostHogClientForTests(mock as unknown as PostHog)
|
||||
_setCommonPropsForTests(BASE_COMMON)
|
||||
_setStoreForTests(store)
|
||||
_setShuttingDownForTests(false)
|
||||
_enableTransportForTests(true)
|
||||
_resetFirstAppOpenedFiredForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_enableTransportForTests(false)
|
||||
_setPostHogClientForTests(null)
|
||||
_setCommonPropsForTests(null)
|
||||
_setStoreForTests(null)
|
||||
_resetFirstAppOpenedFiredForTests()
|
||||
vi.restoreAllMocks()
|
||||
restoreConsentEnv(envStash)
|
||||
})
|
||||
|
||||
it('fires app_opened after re-enabling the SDK and does not emit telemetry_opted_in', async () => {
|
||||
const order: string[] = []
|
||||
mock.optIn.mockImplementation(async () => order.push('optIn'))
|
||||
mock.capture.mockImplementation((message: { event?: string }) => {
|
||||
order.push(`capture:${message.event}`)
|
||||
})
|
||||
|
||||
await persistBannerAcknowledgeWithoutEmitting()
|
||||
|
||||
expect(order).toEqual(['optIn', 'capture:app_opened'])
|
||||
expect(settings.telemetry?.optedIn).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -92,29 +92,10 @@ const OPT_OUT_CAPTURE_ENQUEUE_TIMEOUT_MS = 1_000
|
||||
// be bounded by `resolveConsent` + the validator.
|
||||
let testTransportEnabled = false
|
||||
|
||||
// First-launch `app_opened` gate. The existing-user banner contract in
|
||||
// telemetry-plan.md:177 is: zero events — *not even* `app_opened` — transmit
|
||||
// until the user clicks "Sure". The mechanism is: PR 4's `app_opened` call
|
||||
// site checks `hasFirstAppOpenedFired()` before firing, and `setOptIn(_,
|
||||
// true)` flips the gate via `markFirstAppOpenedFired()`.
|
||||
//
|
||||
// Why this gate exists at all, and why it lives in the client module:
|
||||
// - The consent resolver already returns `pending_banner` for an existing
|
||||
// user with `optedIn === null`, so `track()` would drop `app_opened`
|
||||
// during the pre-consent window anyway. The gate is a belt-and-suspenders
|
||||
// guard for the transition second: between the moment `setOptIn(_, true)`
|
||||
// writes `optedIn = true` to disk and the moment the PR 4 call site fires
|
||||
// `app_opened`, nothing else must sneak a bare `app_opened` onto the wire
|
||||
// using the now-enabled consent. Keeping the gate here — next to
|
||||
// `setOptIn` — means the "flip" is one module-local assignment that no
|
||||
// call-site can bypass accidentally.
|
||||
// - The gate resets to `false` per session by `initTelemetry()` (via the
|
||||
// `resetSessionState()` call below). New users who kept default-on do
|
||||
// not go through `setOptIn` on launch, so PR 4 will also call
|
||||
// `markFirstAppOpenedFired()` once for non-banner cohorts immediately
|
||||
// before firing the session's `app_opened`. PR 3 ships only the state
|
||||
// machinery; PR 4 wires both call sites.
|
||||
let firstAppOpenedFired = false
|
||||
// First-launch `app_opened` session gate. The existing-user banner contract is:
|
||||
// no events transmit until the notice resolves. Keep "mark" and "emit"
|
||||
// atomic so no path can accidentally suppress the event without firing it.
|
||||
let appOpenedTrackedThisSession = false
|
||||
|
||||
function buildCommonProps(installId: string, sid: string, channel: 'stable' | 'rc'): CommonProps {
|
||||
// `.max(64)` on every free-form string field in `commonPropsSchema` is the
|
||||
@@ -140,10 +121,9 @@ export function initTelemetry(store: Store): void {
|
||||
storeRef = store
|
||||
resetBurstCapsForSession()
|
||||
shuttingDown = false
|
||||
// Gate reset per session: the "no app_opened until Sure" invariant is
|
||||
// per-launch, not across the lifetime of the install. See the comment on
|
||||
// `firstAppOpenedFired` above.
|
||||
firstAppOpenedFired = false
|
||||
// Gate reset per session: the "no app_opened until banner resolution"
|
||||
// invariant is per-launch, not across the lifetime of the install.
|
||||
appOpenedTrackedThisSession = false
|
||||
|
||||
if (!TELEMETRY_ENABLED || !IS_OFFICIAL_BUILD) {
|
||||
return
|
||||
@@ -335,6 +315,10 @@ export async function setOptIn(via: OptInVia, optedIn: boolean): Promise<void> {
|
||||
return
|
||||
}
|
||||
const settings = storeRef.getSettings()
|
||||
const telemetryBeforeUpdate = settings.telemetry
|
||||
const wasPendingBanner =
|
||||
telemetryBeforeUpdate?.existedBeforeTelemetryRelease === true &&
|
||||
telemetryBeforeUpdate.optedIn === null
|
||||
// `updateSettings` is a partial-merge (see persistence.ts:552). The Store's
|
||||
// `telemetry` field is deep-merged there specifically so an `optedIn` flip
|
||||
// from the Privacy pane / consent flow does not clobber `installId` or
|
||||
@@ -346,26 +330,19 @@ export async function setOptIn(via: OptInVia, optedIn: boolean): Promise<void> {
|
||||
}
|
||||
})
|
||||
|
||||
// Unlock the first-session `app_opened` after a successful opt-in. The
|
||||
// PR 4 call site is responsible for consulting `hasFirstAppOpenedFired()`
|
||||
// before firing; flipping the gate here is what makes the existing-user
|
||||
// "Sure, help improve Orca" path work — telemetry-plan.md:177 is explicit
|
||||
// that `app_opened` fires only after that click, and this assignment is
|
||||
// the "completes" half of that invariant. Flipping before the
|
||||
// `!posthog` early-return keeps console-mirror builds consistent with
|
||||
// transmitting builds (the gate is semantic, not transport-gated).
|
||||
if (optedIn) {
|
||||
firstAppOpenedFired = true
|
||||
}
|
||||
|
||||
const client = posthog
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
if (optedIn) {
|
||||
await client.optIn()
|
||||
if (client) {
|
||||
await client.optIn()
|
||||
}
|
||||
if (wasPendingBanner) {
|
||||
trackAppOpenedOnce()
|
||||
}
|
||||
track('telemetry_opted_in', { via })
|
||||
} else {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
// Fire opt-out event BEFORE disabling the SDK. This is the one event
|
||||
// that transmits against the user's new preference — the user chose to
|
||||
// tell us they are opting out, and that single signal is what tells us
|
||||
@@ -412,24 +389,26 @@ export async function setOptIn(via: OptInVia, optedIn: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Banner ✕ path. Writes `optedIn = true` permanently and emits NO event.
|
||||
// Banner ✕ path. Writes `optedIn = true` permanently without emitting a
|
||||
// telemetry opt-in event. `app_opened` still fires because resolving the
|
||||
// banner is the first point where this session is eligible to transmit.
|
||||
// That outcome cannot route through `setOptIn()` — `setOptIn()` always
|
||||
// fires a `telemetry_opted_in/out` event and the IPC handler always
|
||||
// derives a non-`null` `via` value, which would tag a ✕ click as
|
||||
// `first_launch_banner` + `telemetry_opted_in`. The ✕-as-silent-
|
||||
// acknowledge contract is explicit: the user did not explicitly opt in,
|
||||
// they declined to intervene, so no event transmits.
|
||||
// they declined to intervene, so no opt-in event transmits.
|
||||
//
|
||||
// So this primitive exists as a named, non-overloaded code path: persist
|
||||
// the opt-in, flip the first-app-opened gate, unlock the SDK, and emit
|
||||
// nothing. The corresponding `telemetry:acknowledgeBanner` IPC channel
|
||||
// the opt-in, unlock the SDK, and fire the once-per-session app-opened event.
|
||||
// The corresponding `telemetry:acknowledgeBanner` IPC channel
|
||||
// routes renderer ✕ clicks here instead of through `telemetry:setOptIn`.
|
||||
//
|
||||
// Do NOT extend this with a `via` parameter or emission flag. If a future
|
||||
// surface also needs a silent persisted opt-in, give it its own named
|
||||
// function rather than overloading this one — the grep'ability of
|
||||
// `persistBannerAcknowledgeWithoutEmitting` is the whole point.
|
||||
export function persistBannerAcknowledgeWithoutEmitting(): void {
|
||||
export async function persistBannerAcknowledgeWithoutEmitting(): Promise<void> {
|
||||
if (!storeRef) {
|
||||
return
|
||||
}
|
||||
@@ -445,31 +424,20 @@ export function persistBannerAcknowledgeWithoutEmitting(): void {
|
||||
optedIn: true
|
||||
}
|
||||
})
|
||||
// Mirror the opt-in half of `setOptIn`: flip the first-app-opened gate
|
||||
// and re-enable the SDK. Without these, a ✕ acknowledge would persist
|
||||
// `optedIn: true` on disk but leave the in-memory SDK flag opted-out
|
||||
// (seeded by `initTelemetry` for pre-banner users whose consent resolver
|
||||
// returned `pending_banner`), so the next `track()` would drop despite
|
||||
// the persisted opt-in. The first-app-opened gate is the "no events
|
||||
// until the user resolves the banner" half of the contract — flipping
|
||||
// it here unlocks PR 4's `app_opened` call site the same way `setOptIn`
|
||||
// does for the Turn-off path.
|
||||
firstAppOpenedFired = true
|
||||
if (posthog) {
|
||||
posthog.optIn()
|
||||
await posthog.optIn()
|
||||
}
|
||||
// Why: resolving the banner is the first eligible moment for app_opened.
|
||||
// Re-enable the SDK first so capture sees the new consent state.
|
||||
trackAppOpenedOnce()
|
||||
}
|
||||
|
||||
// First-launch `app_opened` gate accessors. Used by PR 4's `app_opened`
|
||||
// call site to enforce the "no events transmit for existing users until
|
||||
// Sure" invariant in telemetry-plan.md:177. PR 3 ships the state machinery
|
||||
// only — the call site itself lands in PR 4.
|
||||
export function hasFirstAppOpenedFired(): boolean {
|
||||
return firstAppOpenedFired
|
||||
}
|
||||
|
||||
export function markFirstAppOpenedFired(): void {
|
||||
firstAppOpenedFired = true
|
||||
export function trackAppOpenedOnce(): void {
|
||||
if (appOpenedTrackedThisSession) {
|
||||
return
|
||||
}
|
||||
appOpenedTrackedThisSession = true
|
||||
track('app_opened', {})
|
||||
}
|
||||
|
||||
export async function shutdownTelemetry(): Promise<void> {
|
||||
@@ -521,5 +489,5 @@ export function _enableTransportForTests(enabled: boolean): void {
|
||||
}
|
||||
|
||||
export function _resetFirstAppOpenedFiredForTests(): void {
|
||||
firstAppOpenedFired = false
|
||||
appOpenedTrackedThisSession = false
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('validate', () => {
|
||||
// transmits.
|
||||
it('rejects error_message on agent_error', () => {
|
||||
const result = validate('agent_error', {
|
||||
error_class: 'auth_expired',
|
||||
error_class: 'unknown',
|
||||
agent_kind: 'claude-code',
|
||||
error_message: 'at /Users/alice/secret/path/index.ts:42'
|
||||
} as never)
|
||||
@@ -58,7 +58,7 @@ describe('validate', () => {
|
||||
|
||||
it('rejects error_stack on agent_error', () => {
|
||||
const result = validate('agent_error', {
|
||||
error_class: 'auth_expired',
|
||||
error_class: 'unknown',
|
||||
agent_kind: 'claude-code',
|
||||
error_stack: 'Error: boom\n at /Users/alice/...'
|
||||
} as never)
|
||||
@@ -83,36 +83,21 @@ describe('validate', () => {
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('drops overlength strings past the .max() cap', () => {
|
||||
// commonPropsSchema's `.max(64)` caps don't live on per-event schemas —
|
||||
// the per-event schemas use enum-only strings — so we exercise the cap
|
||||
// via a whitelisted `error_name` that is *one specific enum value*
|
||||
// meaning any attempt to smuggle a long string fails the enum check
|
||||
// regardless. For explicit string-length cap coverage we also confirm
|
||||
// the agent_error schema does not accept an overlength error_name
|
||||
// even if it matches the prefix of a whitelisted value.
|
||||
it('accepts a well-formed agent_error payload', () => {
|
||||
const result = validate('agent_error', {
|
||||
error_class: 'auth_expired',
|
||||
agent_kind: 'claude-code',
|
||||
error_name: 'AuthExpiredButAlsoWithExtraGarbageThatMakesItTooLong'
|
||||
} as never)
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts whitelisted error_name on agent_error', () => {
|
||||
const result = validate('agent_error', {
|
||||
error_class: 'auth_expired',
|
||||
agent_kind: 'claude-code',
|
||||
error_name: 'AuthExpired'
|
||||
error_class: 'binary_not_found',
|
||||
agent_kind: 'claude-code'
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects error_name outside the whitelist', () => {
|
||||
it('rejects agent_error with a deferred enum value', () => {
|
||||
// `auth_expired` was in an earlier draft; the trimmed enum is
|
||||
// ['binary_not_found', 'unknown']. Pin that contract so re-introducing
|
||||
// a deferred value silently is impossible.
|
||||
const result = validate('agent_error', {
|
||||
error_class: 'auth_expired',
|
||||
agent_kind: 'claude-code',
|
||||
error_name: 'CustomErrorNotInList'
|
||||
agent_kind: 'claude-code'
|
||||
} as never)
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
+24
-13
@@ -149,6 +149,7 @@ import type {
|
||||
CodexUsageSummary
|
||||
} from '../shared/codex-usage-types'
|
||||
import type { TelemetryConsentState } from '../shared/telemetry-consent-types'
|
||||
import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events'
|
||||
|
||||
export type BrowserApi = {
|
||||
registerGuest: (args: {
|
||||
@@ -449,6 +450,11 @@ export type PreloadApi = {
|
||||
// Preserved from the deleted index.d.ts PtyApi duplicate during the
|
||||
// single-source-of-truth collapse (see docs/preload-typecheck-hole.md §1).
|
||||
shellOverride?: string
|
||||
// Why: telemetry-plan.md§Agent launch semantics — main emits
|
||||
// `agent_started` only after the PTY/session is created successfully,
|
||||
// so the renderer threads the launch metadata through this field and
|
||||
// the IPC handler fires the event from the spawn-success branch.
|
||||
telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind }
|
||||
}) => Promise<{
|
||||
id: string
|
||||
snapshot?: string
|
||||
@@ -499,7 +505,11 @@ export type PreloadApi = {
|
||||
gh: {
|
||||
viewer: () => Promise<GitHubViewer | null>
|
||||
repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null>
|
||||
prForBranch: (args: { repoPath: string; branch: string }) => Promise<PRInfo | null>
|
||||
prForBranch: (args: {
|
||||
repoPath: string
|
||||
branch: string
|
||||
linkedPRNumber?: number | null
|
||||
}) => Promise<PRInfo | null>
|
||||
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
|
||||
workItem: (args: {
|
||||
repoPath: string
|
||||
@@ -603,9 +613,7 @@ export type PreloadApi = {
|
||||
updateProjectItemField: (
|
||||
args: UpdateProjectItemFieldArgs
|
||||
) => Promise<GitHubProjectMutationResult>
|
||||
clearProjectItemField: (
|
||||
args: ClearProjectItemFieldArgs
|
||||
) => Promise<GitHubProjectMutationResult>
|
||||
clearProjectItemField: (args: ClearProjectItemFieldArgs) => Promise<GitHubProjectMutationResult>
|
||||
updateIssueBySlug: (args: UpdateIssueBySlugArgs) => Promise<GitHubProjectMutationResult>
|
||||
updatePullRequestBySlug: (
|
||||
args: UpdatePullRequestBySlugArgs
|
||||
@@ -624,9 +632,7 @@ export type PreloadApi = {
|
||||
args: ListAssignableUsersBySlugArgs
|
||||
) => Promise<ListAssignableUsersBySlugResult>
|
||||
listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs) => Promise<ListIssueTypesBySlugResult>
|
||||
updateIssueTypeBySlug: (
|
||||
args: UpdateIssueTypeBySlugArgs
|
||||
) => Promise<GitHubProjectMutationResult>
|
||||
updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs) => Promise<GitHubProjectMutationResult>
|
||||
}
|
||||
linear: {
|
||||
connect: (args: {
|
||||
@@ -752,8 +758,9 @@ export type PreloadApi = {
|
||||
}
|
||||
sidekick: {
|
||||
import: () => Promise<CustomSidekick | null>
|
||||
read: (id: string, fileName: string) => Promise<ArrayBuffer | null>
|
||||
delete: (id: string, fileName: string) => Promise<void>
|
||||
importPetBundle: () => Promise<CustomSidekick | null>
|
||||
read: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise<ArrayBuffer | null>
|
||||
delete: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise<void>
|
||||
}
|
||||
browser: BrowserApi
|
||||
hooks: {
|
||||
@@ -1161,11 +1168,15 @@ export type PreloadApi = {
|
||||
listNetworkInterfaces: () => Promise<{
|
||||
interfaces: { name: string; address: string }[]
|
||||
}>
|
||||
getPairingQR: (args?: {
|
||||
address?: string
|
||||
}) => Promise<
|
||||
getPairingQR: (args?: { address?: string }) => Promise<
|
||||
| { available: false }
|
||||
| { available: true; qrDataUrl: string; endpoint: string; deviceId: string }
|
||||
| {
|
||||
available: true
|
||||
qrDataUrl: string
|
||||
pairingUrl: string
|
||||
endpoint: string
|
||||
deviceId: string
|
||||
}
|
||||
>
|
||||
listDevices: () => Promise<{
|
||||
devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[]
|
||||
|
||||
+27
-11
@@ -63,6 +63,7 @@ import type {
|
||||
} from '../shared/ssh-types'
|
||||
import type { AgentStatusState } from '../shared/agent-status-types'
|
||||
import type { TelemetryConsentState } from '../shared/telemetry-consent-types'
|
||||
import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events'
|
||||
import {
|
||||
ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT,
|
||||
type EditorSaveDirtyFilesDetail
|
||||
@@ -383,6 +384,12 @@ const api = {
|
||||
worktreeId?: string
|
||||
sessionId?: string
|
||||
shellOverride?: string
|
||||
// Why: telemetry-plan.md§Agent launch semantics — main fires
|
||||
// `agent_started` only after the spawn succeeds. The renderer is the
|
||||
// source of truth for the launch metadata; main is the source of
|
||||
// truth for whether the launch happened. Loose typing here on
|
||||
// purpose: validation lives at the main-side schema validator.
|
||||
telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind }
|
||||
}): Promise<{
|
||||
id: string
|
||||
snapshot?: string
|
||||
@@ -522,8 +529,11 @@ const api = {
|
||||
repoSlug: (args: { repoPath: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gh:repoSlug', args),
|
||||
|
||||
prForBranch: (args: { repoPath: string; branch: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gh:prForBranch', args),
|
||||
prForBranch: (args: {
|
||||
repoPath: string
|
||||
branch: string
|
||||
linkedPRNumber?: number | null
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gh:prForBranch', args),
|
||||
|
||||
issue: (args: { repoPath: string; number: number }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gh:issue', args),
|
||||
@@ -678,8 +688,7 @@ const api = {
|
||||
ipcRenderer.invoke('gh:updateProjectItemField', args),
|
||||
clearProjectItemField: (
|
||||
args: ClearProjectItemFieldArgs
|
||||
): Promise<GitHubProjectMutationResult> =>
|
||||
ipcRenderer.invoke('gh:clearProjectItemField', args),
|
||||
): Promise<GitHubProjectMutationResult> => ipcRenderer.invoke('gh:clearProjectItemField', args),
|
||||
updateIssueBySlug: (args: UpdateIssueBySlugArgs): Promise<GitHubProjectMutationResult> =>
|
||||
ipcRenderer.invoke('gh:updateIssueBySlug', args),
|
||||
updatePullRequestBySlug: (
|
||||
@@ -708,8 +717,7 @@ const api = {
|
||||
ipcRenderer.invoke('gh:listIssueTypesBySlug', args),
|
||||
updateIssueTypeBySlug: (
|
||||
args: UpdateIssueTypeBySlugArgs
|
||||
): Promise<GitHubProjectMutationResult> =>
|
||||
ipcRenderer.invoke('gh:updateIssueTypeBySlug', args)
|
||||
): Promise<GitHubProjectMutationResult> => ipcRenderer.invoke('gh:updateIssueTypeBySlug', args)
|
||||
},
|
||||
|
||||
linear: {
|
||||
@@ -978,10 +986,12 @@ const api = {
|
||||
|
||||
sidekick: {
|
||||
import: (): Promise<CustomSidekick | null> => ipcRenderer.invoke('sidekick:import'),
|
||||
read: (id: string, fileName: string): Promise<ArrayBuffer | null> =>
|
||||
ipcRenderer.invoke('sidekick:read', id, fileName),
|
||||
delete: (id: string, fileName: string): Promise<void> =>
|
||||
ipcRenderer.invoke('sidekick:delete', id, fileName)
|
||||
importPetBundle: (): Promise<CustomSidekick | null> =>
|
||||
ipcRenderer.invoke('sidekick:importPetBundle'),
|
||||
read: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise<ArrayBuffer | null> =>
|
||||
ipcRenderer.invoke('sidekick:read', id, fileName, kind),
|
||||
delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise<void> =>
|
||||
ipcRenderer.invoke('sidekick:delete', id, fileName, kind)
|
||||
},
|
||||
|
||||
browser: {
|
||||
@@ -2125,7 +2135,13 @@ const api = {
|
||||
address?: string
|
||||
}): Promise<
|
||||
| { available: false }
|
||||
| { available: true; qrDataUrl: string; endpoint: string; deviceId: string }
|
||||
| {
|
||||
available: true
|
||||
qrDataUrl: string
|
||||
pairingUrl: string
|
||||
endpoint: string
|
||||
deviceId: string
|
||||
}
|
||||
> => ipcRenderer.invoke('mobile:getPairingQR', args),
|
||||
|
||||
listDevices: (): Promise<{
|
||||
|
||||
+116
-104
@@ -957,118 +957,130 @@ function App(): React.JSX.Element {
|
||||
The hooks' internal early-returns remain as defense-in-depth
|
||||
(see the comment above useIpcEvents()). */}
|
||||
{agentDashboardEnabled ? <RetainedAgentsSyncGate /> : null}
|
||||
{/* Why: in workspace view (split groups always enabled), the full-width
|
||||
titlebar is removed so tab groups + terminal extend to the top of
|
||||
the window. Left titlebar controls move to a header above the sidebar.
|
||||
Settings, landing, and the tasks page keep the full-width titlebar. */}
|
||||
{!workspaceActive ? (
|
||||
<div className="titlebar">
|
||||
<div
|
||||
className={`flex items-center${showSidebar && sidebarOpen ? ' overflow-hidden shrink-0' : ' shrink-0 mr-2'}`}
|
||||
style={{ width: showSidebar && sidebarOpen ? sidebarWidth : undefined }}
|
||||
>
|
||||
{titlebarLeftControls}
|
||||
</div>
|
||||
<div
|
||||
id="titlebar-tabs"
|
||||
className={`flex flex-1 min-w-0 self-stretch${activeView !== 'terminal' || !activeWorktreeId ? ' invisible pointer-events-none' : ''}`}
|
||||
/>
|
||||
{showTitlebarExpandButton && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="titlebar-icon-button"
|
||||
onClick={handleToggleExpand}
|
||||
aria-label="Collapse pane"
|
||||
disabled={!activeTabCanExpand}
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Collapse pane
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{rightSidebarToggle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
|
||||
{showSidebar ? (
|
||||
workspaceActive ? (
|
||||
/* Why: left column wraps the sidebar with a titlebar-height
|
||||
header above it. The header holds the same controls
|
||||
(traffic lights, sidebar toggle, "Orca" title, agent badge)
|
||||
that the full-width titlebar held while the center and right
|
||||
columns keep their own top strips at the same 42px height.
|
||||
When the sidebar is collapsed, take this header out of flex
|
||||
layout so the terminal/editor reclaim the left edge instead of
|
||||
leaving behind a content-width blank strip. */
|
||||
<div
|
||||
className={`flex min-h-0 flex-col shrink-0${sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
|
||||
>
|
||||
{/* Why: the non-workspace titlebar lives inside this left+center
|
||||
wrapper so it does not span over the right-sidebar column —
|
||||
when the right sidebar is open, its own header anchors at the
|
||||
top alongside the titlebar instead of being pushed below it. */}
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||
{/* Why: in workspace view (split groups always enabled), the
|
||||
full-width titlebar is removed so tab groups + terminal extend
|
||||
to the top of the window. Left titlebar controls move to a
|
||||
header above the sidebar. Settings, landing, and the tasks
|
||||
page keep the titlebar. */}
|
||||
{!workspaceActive ? (
|
||||
<div className="titlebar">
|
||||
<div
|
||||
// Why: when the sidebar is collapsed, titlebar-left floats
|
||||
// absolutely on top of the center column's own `border-l`
|
||||
// (see TabGroupSplitLayout), occluding that seam. Add a
|
||||
// `border-r` in the floating state so the vertical line
|
||||
// between the traffic-light/nav cluster and the tab strip
|
||||
// stays visible in both states.
|
||||
className={`titlebar-left${sidebarOpen ? '' : ' absolute top-0 left-0 z-10 border-r border-border'}`}
|
||||
style={{
|
||||
// Why: the Sidebar resize hook updates the sidebar DOM width
|
||||
// directly during drag and only persists to Zustand on
|
||||
// mouseup. In workspace view, size this header from the
|
||||
// wrapper's live width so it tracks those in-flight resizes
|
||||
// instead of leaving a stale-width gap until the drag ends.
|
||||
width: sidebarOpen ? '100%' : undefined
|
||||
}}
|
||||
className={`flex items-center${showSidebar && sidebarOpen ? ' overflow-hidden shrink-0' : ' shrink-0 mr-2'}`}
|
||||
style={{ width: showSidebar && sidebarOpen ? sidebarWidth : undefined }}
|
||||
>
|
||||
{titlebarLeftControls}
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Why: the workspace-view wrapper adds a fixed 42px header
|
||||
above the sidebar. Without a flex-1/min-h-0 slot here,
|
||||
the sidebar falls back to its content height, so the
|
||||
worktree list loses its scroll viewport and the fixed
|
||||
bottom toolbar (including Add Project) gets pushed offscreen. */}
|
||||
<div
|
||||
id="titlebar-tabs"
|
||||
className={`flex flex-1 min-w-0 self-stretch${activeView !== 'terminal' || !activeWorktreeId ? ' invisible pointer-events-none' : ''}`}
|
||||
/>
|
||||
{showTitlebarExpandButton && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="titlebar-icon-button"
|
||||
onClick={handleToggleExpand}
|
||||
aria-label="Collapse pane"
|
||||
disabled={!activeTabCanExpand}
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Collapse pane
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Why: when the right sidebar is open, its own header renders
|
||||
an identical close button — hide this copy so only one is
|
||||
visible at a time. */}
|
||||
{!rightSidebarOpen && rightSidebarToggle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
|
||||
{showSidebar ? (
|
||||
workspaceActive ? (
|
||||
/* Why: left column wraps the sidebar with a titlebar-height
|
||||
header above it. The header holds the same controls
|
||||
(traffic lights, sidebar toggle, "Orca" title, agent badge)
|
||||
that the full-width titlebar held while the center and right
|
||||
columns keep their own top strips at the same 36px height.
|
||||
When the sidebar is collapsed, take this header out of flex
|
||||
layout so the terminal/editor reclaim the left edge instead of
|
||||
leaving behind a content-width blank strip. */
|
||||
<div
|
||||
className={`flex min-h-0 flex-col shrink-0${sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
|
||||
>
|
||||
<div
|
||||
// Why: when the sidebar is collapsed, titlebar-left floats
|
||||
// absolutely on top of the center column's own `border-l`
|
||||
// (see TabGroupSplitLayout), occluding that seam. Add a
|
||||
// `border-r` in the floating state so the vertical line
|
||||
// between the traffic-light/nav cluster and the tab strip
|
||||
// stays visible in both states.
|
||||
className={`titlebar-left${sidebarOpen ? '' : ' absolute top-0 left-0 z-10 border-r border-border'}`}
|
||||
style={{
|
||||
// Why: the Sidebar resize hook updates the sidebar DOM width
|
||||
// directly during drag and only persists to Zustand on
|
||||
// mouseup. In workspace view, size this header from the
|
||||
// wrapper's live width so it tracks those in-flight resizes
|
||||
// instead of leaving a stale-width gap until the drag ends.
|
||||
width: sidebarOpen ? '100%' : undefined
|
||||
}}
|
||||
>
|
||||
{titlebarLeftControls}
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Why: the workspace-view wrapper adds a fixed 36px header
|
||||
above the sidebar. Without a flex-1/min-h-0 slot here,
|
||||
the sidebar falls back to its content height, so the
|
||||
worktree list loses its scroll viewport and the fixed
|
||||
bottom toolbar (including Add Project) gets pushed offscreen. */}
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Sidebar />
|
||||
)
|
||||
) : null}
|
||||
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{/* Why: right sidebar toggle floats at the top-right of the center
|
||||
column so it's always accessible whether the right sidebar is
|
||||
open or closed. Match the RightSidebar header's 36px height and
|
||||
top-0 anchor so the icon's vertical center is identical between
|
||||
open and closed states — otherwise toggling makes the icon jump
|
||||
a few pixels, which reads as layout jitter. */}
|
||||
{workspaceActive && !rightSidebarOpen && (
|
||||
<div
|
||||
className="absolute top-0 right-0 z-10 flex items-center h-[36px]"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
{rightSidebarToggle}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
|
||||
<div
|
||||
className={
|
||||
activeView !== 'terminal' || !activeWorktreeId
|
||||
? 'hidden flex-1 min-w-0 min-h-0'
|
||||
: 'flex flex-1 min-w-0 min-h-0'
|
||||
}
|
||||
>
|
||||
<Terminal />
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
{activeView === 'settings' ? <Settings /> : null}
|
||||
{activeView === 'tasks' ? <TaskPage /> : null}
|
||||
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Sidebar />
|
||||
)
|
||||
) : null}
|
||||
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{/* Why: right sidebar toggle floats at the top-right of the center
|
||||
column so it's always accessible whether the right sidebar is
|
||||
open or closed. Match the RightSidebar header's 42px height and
|
||||
top-0 anchor so the icon's vertical center is identical between
|
||||
open and closed states — otherwise toggling makes the icon jump
|
||||
a few pixels, which reads as layout jitter. */}
|
||||
{workspaceActive && !rightSidebarOpen && (
|
||||
<div
|
||||
className="absolute top-0 right-0 z-10 flex items-center h-[36px]"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
{rightSidebarToggle}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
|
||||
<div
|
||||
className={
|
||||
activeView !== 'terminal' || !activeWorktreeId
|
||||
? 'hidden flex-1 min-w-0 min-h-0'
|
||||
: 'flex flex-1 min-w-0 min-h-0'
|
||||
}
|
||||
>
|
||||
<Terminal />
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
{activeView === 'settings' ? <Settings /> : null}
|
||||
{activeView === 'tasks' ? <TaskPage /> : null}
|
||||
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
{/* Why: keep RightSidebar mounted even when closed so that its
|
||||
|
||||
@@ -359,8 +359,8 @@
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
height: 42px;
|
||||
min-height: 42px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//
|
||||
// Three actions, two semantics:
|
||||
// - "Got it" and the ✕ in the corner → silent acknowledge. Both persist
|
||||
// `optedIn: true`, fire nothing, route through
|
||||
// `optedIn: true`, fire no opt-in event, route through
|
||||
// `window.api.telemetryAcknowledgeBanner()` to a dedicated main-side
|
||||
// channel so no `via` derivation can tag this path. Two surfaces for
|
||||
// the same action because the ✕ alone is easy to miss; "Got it" is
|
||||
@@ -64,16 +64,24 @@ export function FirstLaunchBanner({
|
||||
}
|
||||
setInFlight(true)
|
||||
// Main's `telemetry:acknowledgeBanner` handler persists `optedIn: true`
|
||||
// silently (no event) and intentionally does NOT broadcast
|
||||
// without an opt-in event and intentionally does NOT broadcast
|
||||
// `settings:changed` (see src/main/ipc/telemetry.ts). Without an
|
||||
// explicit `fetchSettings()` refresh, the renderer store would retain
|
||||
// `optedIn: null` and PrivacyPane would keep rendering its pending-
|
||||
// banner helper text until the next full relaunch. Mirror
|
||||
// PrivacyPane's handleToggle pattern which refetches for the same
|
||||
// reason before surfacing UI changes.
|
||||
await acknowledgeBanner()
|
||||
await fetchSettings()
|
||||
onResolve()
|
||||
try {
|
||||
await acknowledgeBanner()
|
||||
await fetchSettings()
|
||||
onResolve()
|
||||
} finally {
|
||||
// Why: if `fetchSettings` rejects (IPC error during shutdown,
|
||||
// settings file lock, etc.), `onResolve` never runs and the banner
|
||||
// stays mounted. Without resetting `inFlight`, every button stays
|
||||
// permanently disabled for the rest of the session.
|
||||
setInFlight(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTurnOff = async (): Promise<void> => {
|
||||
@@ -86,9 +94,13 @@ export function FirstLaunchBanner({
|
||||
// `setOptIn` (client.ts). The renderer just needs to route through
|
||||
// `telemetrySetOptIn(false)` so the IPC handler derives the correct
|
||||
// `via` and fires the event.
|
||||
await telemetrySetOptIn(false)
|
||||
await fetchSettings()
|
||||
onResolve()
|
||||
try {
|
||||
await telemetrySetOptIn(false)
|
||||
await fetchSettings()
|
||||
onResolve()
|
||||
} finally {
|
||||
setInFlight(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -103,7 +115,7 @@ export function FirstLaunchBanner({
|
||||
// `relative` is load-bearing: the absolutely-positioned ✕ anchors to
|
||||
// this container.
|
||||
<div
|
||||
className="fixed left-1/2 top-2 z-40 flex w-[min(46.5rem,calc(100vw-2rem))] -translate-x-1/2 items-start gap-4 rounded-lg border border-border bg-card/95 py-3 pl-4 pr-3 shadow-lg backdrop-blur"
|
||||
className="fixed left-1/2 top-2 z-40 flex w-[min(44.625rem,calc(100vw-2rem))] -translate-x-1/2 items-start gap-4 rounded-lg border border-border bg-card/95 py-3 pl-4 pr-3 shadow-lg backdrop-blur"
|
||||
role="region"
|
||||
aria-label="Telemetry notice"
|
||||
aria-live="polite"
|
||||
@@ -111,11 +123,11 @@ export function FirstLaunchBanner({
|
||||
{/* Text column — title + body stack on the left, takes remaining
|
||||
width so the action column never pushes copy into a wrap. */}
|
||||
<div className="flex-1 space-y-0.5 pr-1 text-sm">
|
||||
<p className="font-medium leading-snug">Help us figure out what to build next</p>
|
||||
<p className="font-medium leading-snug">Help us decide what to build next</p>
|
||||
<p className="text-xs leading-snug text-muted-foreground">
|
||||
We'd like to start sending anonymous counts of which features you use and where
|
||||
things break — no file contents, prompts, terminal output, or anything that identifies
|
||||
you. Change this anytime in Settings → Privacy & Telemetry.{' '}
|
||||
Anonymous counts of which features you use help us prioritize what to build. No file
|
||||
contents, prompts, terminal output, or anything that identifies you. Change anytime in
|
||||
Settings → Privacy & Telemetry.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
@@ -133,8 +145,14 @@ export function FirstLaunchBanner({
|
||||
choice. ✕ stays in the corner as a familiar escape and to
|
||||
satisfy keyboard/notification-style dismiss expectations. */}
|
||||
<div className="flex shrink-0 items-center gap-2 self-center pr-6">
|
||||
<Button variant="outline" size="sm" onClick={handleTurnOff} disabled={inFlight}>
|
||||
Turn off
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTurnOff}
|
||||
disabled={inFlight}
|
||||
className="border-border/60 text-muted-foreground"
|
||||
>
|
||||
Opt out
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleAcknowledge} disabled={inFlight}>
|
||||
Got it
|
||||
|
||||
@@ -61,15 +61,8 @@ import {
|
||||
type PRCommentGroup
|
||||
} from '@/lib/pr-comment-groups'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
useRepoLabels,
|
||||
useRepoAssignees,
|
||||
useImmediateMutation
|
||||
} from '@/hooks/useIssueMetadata'
|
||||
import {
|
||||
useRepoLabelsBySlug,
|
||||
useRepoAssigneesBySlug
|
||||
} from '@/hooks/useGitHubSlugMetadata'
|
||||
import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata'
|
||||
import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
|
||||
import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator'
|
||||
import type {
|
||||
GitHubOwnerRepo,
|
||||
@@ -1682,14 +1675,12 @@ function MentionTextarea({
|
||||
// repo. The edit IPCs return a structured `{ ok, error }` shape; we adapt
|
||||
// to a thrown rejection so the existing `useImmediateMutation` flow
|
||||
// (which expects throws on failure) continues to work unchanged.
|
||||
async function runIssueUpdate(
|
||||
args: {
|
||||
repoPath: string | null
|
||||
projectOrigin: GitHubItemDialogProjectOrigin | undefined
|
||||
number: number
|
||||
updates: Parameters<typeof window.api.gh.updateIssue>[0]['updates']
|
||||
}
|
||||
): Promise<void> {
|
||||
async function runIssueUpdate(args: {
|
||||
repoPath: string | null
|
||||
projectOrigin: GitHubItemDialogProjectOrigin | undefined
|
||||
number: number
|
||||
updates: Parameters<typeof window.api.gh.updateIssue>[0]['updates']
|
||||
}): Promise<void> {
|
||||
if (args.projectOrigin) {
|
||||
const res = await window.api.gh.updateIssueBySlug({
|
||||
owner: args.projectOrigin.owner,
|
||||
@@ -1746,7 +1737,9 @@ function GHEditSection({
|
||||
// See docs/design/github-project-view-tasks.md §Dialog editing from Project rows.
|
||||
const patchProjectRowIfNeeded = useCallback(
|
||||
(patch: Parameters<typeof patchProjectRowContent>[2]) => {
|
||||
if (!projectOrigin) {return}
|
||||
if (!projectOrigin) {
|
||||
return
|
||||
}
|
||||
patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, patch)
|
||||
},
|
||||
[projectOrigin, patchProjectRowContent]
|
||||
|
||||
@@ -252,7 +252,7 @@ export default function Landing(): React.JSX.Element {
|
||||
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-sm px-4 py-2 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed enabled:cursor-pointer enabled:hover:bg-accent"
|
||||
disabled={!canCreateWorktree}
|
||||
title={!canCreateWorktree ? 'Add a Git project first' : undefined}
|
||||
onClick={() => openModal('new-workspace-composer')}
|
||||
onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })}
|
||||
>
|
||||
<GitBranchPlus className="size-3.5" />
|
||||
Create Worktree
|
||||
|
||||
@@ -398,6 +398,25 @@ export default function NewWorkspaceComposerCard({
|
||||
: '-translate-y-1 opacity-0 delay-0'
|
||||
)}
|
||||
>
|
||||
{smartNameSelection ? (
|
||||
// Why: when a source (PR/issue/Linear/branch) is picked the
|
||||
// smart field shows a pill instead of an editable name, so
|
||||
// surface the auto-derived workspace name here under Advanced
|
||||
// where it can be reviewed/overridden. When the user typed an
|
||||
// explicit name there's no source pill — the smart input is
|
||||
// already the name field, so we don't duplicate it here.
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => onNameValueChange(event.target.value)}
|
||||
placeholder="Workspace name"
|
||||
className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Note</label>
|
||||
<textarea
|
||||
|
||||
@@ -7,13 +7,18 @@ import { useComposerState } from '@/hooks/useComposerState'
|
||||
import { AGENT_CATALOG } from '@/lib/agent-catalog'
|
||||
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
|
||||
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
import type { TuiAgent, WorkspaceCreateTelemetrySource } from '../../../shared/types'
|
||||
|
||||
type ComposerModalData = {
|
||||
prefilledName?: string
|
||||
initialRepoId?: string
|
||||
linkedWorkItem?: LinkedWorkItemSummary | null
|
||||
initialBaseBranch?: string
|
||||
/** Telemetry surface that opened the composer. Set by each
|
||||
* `openModal('new-workspace-composer', ...)` site so
|
||||
* `workspace_created.source` carries the right value. Falls back to
|
||||
* `unknown` when omitted. */
|
||||
telemetrySource?: WorkspaceCreateTelemetrySource
|
||||
}
|
||||
|
||||
export default function NewWorkspaceComposerModal(): React.JSX.Element | null {
|
||||
@@ -101,7 +106,8 @@ function QuickTabBody({
|
||||
initialRepoId: modalData.initialRepoId,
|
||||
...(modalData.initialBaseBranch ? { initialBaseBranch: modalData.initialBaseBranch } : {}),
|
||||
persistDraft: false,
|
||||
onCreated: onClose
|
||||
onCreated: onClose,
|
||||
...(modalData.telemetrySource ? { telemetrySource: modalData.telemetrySource } : {})
|
||||
})
|
||||
// Why: the composer's built-in `onOpenAgentSettings` handler navigates to
|
||||
// the settings page and closes the modal. For the quick-create flow we want
|
||||
|
||||
@@ -653,6 +653,9 @@ const hasUpstreamCandidateDivergence = (
|
||||
|
||||
export default function TaskPage(): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
|
||||
const taskResumeState = useAppStore((s) => s.taskResumeState)
|
||||
const setTaskResumeState = useAppStore((s) => s.setTaskResumeState)
|
||||
const pageData = useAppStore((s) => s.taskPageData)
|
||||
const closeTaskPage = useAppStore((s) => s.closeTaskPage)
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
@@ -675,7 +678,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
const listLinearIssues = useAppStore((s) => s.listLinearIssues)
|
||||
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
|
||||
// Why: in workspace view (a worktree is active) App.tsx hides its
|
||||
// full-width titlebar, so this page renders its own 42px titlebar strip to
|
||||
// full-width titlebar, so this page renders its own 36px titlebar strip to
|
||||
// keep the top band continuous with the sidebar header and tab rows. When
|
||||
// the sidebar is also collapsed, App.tsx floats its titlebar-left controls
|
||||
// (traffic lights, sidebar toggle, agent badge) over our strip — reserve
|
||||
@@ -768,6 +771,10 @@ export default function TaskPage(): React.JSX.Element {
|
||||
|
||||
const defaultTaskSource = settings?.defaultTaskSource ?? 'github'
|
||||
const [taskSource, setTaskSource] = useState<TaskSource>(pageData.taskSource ?? defaultTaskSource)
|
||||
const taskResumeAppliedRef = useRef(false)
|
||||
const githubSearchPersistReadyRef = useRef(false)
|
||||
const linearSearchPersistReadyRef = useRef(false)
|
||||
const [taskResumeApplied, setTaskResumeApplied] = useState(false)
|
||||
|
||||
// Why: pageData.taskSource changes when the user clicks a specific source
|
||||
// icon in the sidebar while the task page is already open. useState only
|
||||
@@ -778,9 +785,11 @@ export default function TaskPage(): React.JSX.Element {
|
||||
}
|
||||
}, [pageData.taskSource])
|
||||
|
||||
// Why: settings load asynchronously — the useState initializer may capture
|
||||
// null settings on fast navigation. Sync once settings arrive, but only
|
||||
// when no explicit source was passed via sidebar icon click.
|
||||
// Why: settings.defaultTaskSource can change while Tasks is open (Settings
|
||||
// panel is reachable without unmounting Tasks). The one-shot resume effect
|
||||
// only seeds taskSource on first mount, so without this sync a mid-session
|
||||
// change to the default would not propagate when no explicit page-level
|
||||
// taskSource was passed.
|
||||
useEffect(() => {
|
||||
if (!pageData.taskSource && settings?.defaultTaskSource) {
|
||||
setTaskSource(settings.defaultTaskSource)
|
||||
@@ -792,13 +801,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
// gated on a non-null `activeProject` once they pick one.
|
||||
const projectModeVisible = taskSource === 'github'
|
||||
const [githubMode, setGithubMode] = useState<'items' | 'project'>('items')
|
||||
useEffect(() => {
|
||||
// Snap back to items if the user leaves the GitHub task source while
|
||||
// sitting in Project mode.
|
||||
if (!projectModeVisible && githubMode === 'project') {
|
||||
setGithubMode('items')
|
||||
}
|
||||
}, [projectModeVisible, githubMode])
|
||||
|
||||
const [taskSearchInput, setTaskSearchInput] = useState(initialTaskQuery)
|
||||
const [appliedTaskSearch, setAppliedTaskSearch] = useState(initialTaskQuery)
|
||||
@@ -1033,9 +1035,47 @@ export default function TaskPage(): React.JSX.Element {
|
||||
const [linearLoading, setLinearLoading] = useState(false)
|
||||
const [linearError, setLinearError] = useState<string | null>(null)
|
||||
const [linearSearchInput, setLinearSearchInput] = useState('')
|
||||
const [appliedLinearSearch, setAppliedLinearSearch] = useState('')
|
||||
const [activeLinearPreset, setActiveLinearPreset] = useState<LinearPresetId>('all')
|
||||
const [linearRefreshNonce, setLinearRefreshNonce] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (taskResumeAppliedRef.current || !persistedUIReady || !settings) {
|
||||
return
|
||||
}
|
||||
|
||||
setTaskSource(pageData.taskSource ?? settings.defaultTaskSource)
|
||||
setRepoSelection(resolvedInitialSelection)
|
||||
|
||||
const nextGithubMode = taskResumeState?.githubMode ?? 'items'
|
||||
setGithubMode(nextGithubMode)
|
||||
|
||||
const preset = taskResumeState?.githubItemsPreset
|
||||
if (preset === null) {
|
||||
const query = taskResumeState?.githubItemsQuery ?? ''
|
||||
setTaskSearchInput(query)
|
||||
setAppliedTaskSearch(query)
|
||||
setActiveTaskPreset(null)
|
||||
} else {
|
||||
const presetId = preset ?? settings.defaultTaskViewPreset
|
||||
const query = getTaskPresetQuery(presetId)
|
||||
setTaskSearchInput(query)
|
||||
setAppliedTaskSearch(query)
|
||||
setActiveTaskPreset(presetId)
|
||||
}
|
||||
|
||||
const linearPreset = taskResumeState?.linearPreset ?? 'all'
|
||||
const linearQuery = taskResumeState?.linearQuery ?? ''
|
||||
setActiveLinearPreset(linearPreset)
|
||||
setLinearSearchInput(linearQuery)
|
||||
setAppliedLinearSearch(linearQuery)
|
||||
|
||||
// Why: settings and persisted UI hydrate asynchronously. Apply the restored
|
||||
// Tasks context exactly once so later source/filter clicks remain local.
|
||||
taskResumeAppliedRef.current = true
|
||||
setTaskResumeApplied(true)
|
||||
}, [persistedUIReady, settings, pageData.taskSource, resolvedInitialSelection, taskResumeState])
|
||||
|
||||
// Why: fetch the full team list from the Linear API so the selector shows
|
||||
// all teams the user belongs to, not just teams with issues in the current
|
||||
// fetch window. Fetched once when the Linear tab is active and connected.
|
||||
@@ -1044,6 +1084,9 @@ export default function TaskPage(): React.JSX.Element {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskResumeApplied) {
|
||||
return
|
||||
}
|
||||
if (taskSource !== 'linear' || !linearStatus.connected) {
|
||||
return
|
||||
}
|
||||
@@ -1054,7 +1097,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
console.warn('[TaskPage] Failed to fetch Linear teams')
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskSource, linearStatus.connected])
|
||||
}, [taskSource, linearStatus.connected, taskResumeApplied])
|
||||
|
||||
const defaultLinearTeamSelection = settings?.defaultLinearTeamSelection
|
||||
const [linearTeamSelection, setLinearTeamSelection] = useState<ReadonlySet<string>>(() => {
|
||||
@@ -1197,19 +1240,45 @@ export default function TaskPage(): React.JSX.Element {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskResumeApplied) {
|
||||
return
|
||||
}
|
||||
const timeout = window.setTimeout(() => {
|
||||
setAppliedTaskSearch(taskSearchInput)
|
||||
}, TASK_SEARCH_DEBOUNCE_MS)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [taskSearchInput])
|
||||
}, [taskSearchInput, taskResumeApplied])
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskResumeApplied) {
|
||||
return
|
||||
}
|
||||
if (!githubSearchPersistReadyRef.current) {
|
||||
githubSearchPersistReadyRef.current = true
|
||||
return
|
||||
}
|
||||
// Why: persist the debounced applied query regardless of the active
|
||||
// preset. The preset-click handler writes the canonical query for that
|
||||
// preset, so persisting again here is at worst idempotent. When the
|
||||
// user types into the search box `handleTaskSearchChange` clears the
|
||||
// preset, but persisting unconditionally also covers paths that change
|
||||
// appliedTaskSearch without going through that handler.
|
||||
setTaskResumeState({
|
||||
githubItemsPreset: activeTaskPreset,
|
||||
githubItemsQuery: appliedTaskSearch.trim()
|
||||
})
|
||||
}, [activeTaskPreset, appliedTaskSearch, setTaskResumeState, taskResumeApplied])
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskResumeApplied) {
|
||||
return
|
||||
}
|
||||
// Why: both early-return branches must clear `retryingRepoPaths` — if the
|
||||
// user clicks Retry and then switches `taskSource` away from 'github' (or
|
||||
// somehow ends up with zero repos selected) before the fetch dispatches,
|
||||
// neither the `.then` nor the `.catch` below will fire, and the Retry
|
||||
// button would stay stuck in its disabled/Retrying state indefinitely.
|
||||
if (taskSource !== 'github') {
|
||||
if (taskSource !== 'github' || githubMode !== 'items') {
|
||||
setRetryingRepoPaths(new Set())
|
||||
return
|
||||
}
|
||||
@@ -1347,15 +1416,24 @@ export default function TaskPage(): React.JSX.Element {
|
||||
// updates. `workItemsInvalidationNonce` is explicitly included so a
|
||||
// preference flip (which only evicts cache) re-dispatches this effect.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedRepos, appliedTaskSearch, taskRefreshNonce, taskSource, workItemsInvalidationNonce])
|
||||
}, [
|
||||
selectedRepos,
|
||||
appliedTaskSearch,
|
||||
taskRefreshNonce,
|
||||
taskSource,
|
||||
githubMode,
|
||||
workItemsInvalidationNonce,
|
||||
taskResumeApplied
|
||||
])
|
||||
|
||||
const handleApplyTaskSearch = useCallback((): void => {
|
||||
const trimmed = taskSearchInput.trim()
|
||||
setTaskSearchInput(trimmed)
|
||||
setAppliedTaskSearch(trimmed)
|
||||
setActiveTaskPreset(null)
|
||||
setTaskResumeState({ githubItemsPreset: null, githubItemsQuery: trimmed })
|
||||
setTaskRefreshNonce((current) => current + 1)
|
||||
}, [taskSearchInput])
|
||||
}, [setTaskResumeState, taskSearchInput])
|
||||
|
||||
const handleTaskSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const next = event.target.value
|
||||
@@ -1405,7 +1483,8 @@ export default function TaskPage(): React.JSX.Element {
|
||||
openModal('new-workspace-composer', {
|
||||
linkedWorkItem,
|
||||
prefilledName: getLinkedWorkItemSuggestedName(item),
|
||||
initialRepoId: item.repoId
|
||||
initialRepoId: item.repoId,
|
||||
telemetrySource: 'sidebar'
|
||||
})
|
||||
},
|
||||
[openModal]
|
||||
@@ -1418,7 +1497,8 @@ export default function TaskPage(): React.JSX.Element {
|
||||
// setup before the worktree is created. Earlier the "Use" CTA created
|
||||
// and activated the worktree synchronously, which was disorienting —
|
||||
// the worktree appeared in the sidebar before the user had a chance
|
||||
// to review it. The composer already owns the prefill flow.
|
||||
// to review it. The composer already owns the prefill flow. Telemetry
|
||||
// attribution flows via `openComposerForItem` (sets telemetrySource).
|
||||
openComposerForItem(item)
|
||||
},
|
||||
[openComposerForItem]
|
||||
@@ -1605,18 +1685,34 @@ export default function TaskPage(): React.JSX.Element {
|
||||
|
||||
// Why: debounce the Linear search input so we don't fire a request on every
|
||||
// keystroke — matches the 300ms cadence used for GitHub search.
|
||||
const [appliedLinearSearch, setAppliedLinearSearch] = useState('')
|
||||
useEffect(() => {
|
||||
if (!taskResumeApplied) {
|
||||
return
|
||||
}
|
||||
const timeout = window.setTimeout(() => {
|
||||
setAppliedLinearSearch(linearSearchInput)
|
||||
}, TASK_SEARCH_DEBOUNCE_MS)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [linearSearchInput])
|
||||
}, [linearSearchInput, taskResumeApplied])
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskResumeApplied) {
|
||||
return
|
||||
}
|
||||
if (!linearSearchPersistReadyRef.current) {
|
||||
linearSearchPersistReadyRef.current = true
|
||||
return
|
||||
}
|
||||
setTaskResumeState({ linearQuery: appliedLinearSearch.trim() })
|
||||
}, [appliedLinearSearch, setTaskResumeState, taskResumeApplied])
|
||||
|
||||
// Why: fetch Linear issues when the tab is active and the account is
|
||||
// connected. An empty search falls back to `listLinearIssues` (assigned
|
||||
// issues) so the default view shows the user's own work.
|
||||
useEffect(() => {
|
||||
if (!taskResumeApplied) {
|
||||
return
|
||||
}
|
||||
if (taskSource !== 'linear') {
|
||||
return
|
||||
}
|
||||
@@ -1661,7 +1757,8 @@ export default function TaskPage(): React.JSX.Element {
|
||||
linearStatus.connected,
|
||||
appliedLinearSearch,
|
||||
activeLinearPreset,
|
||||
linearRefreshNonce
|
||||
linearRefreshNonce,
|
||||
taskResumeApplied
|
||||
])
|
||||
|
||||
// Why: for Linear issues the "Use" flow opens the composer with the issue
|
||||
@@ -1678,7 +1775,8 @@ export default function TaskPage(): React.JSX.Element {
|
||||
}
|
||||
openModal('new-workspace-composer', {
|
||||
linkedWorkItem,
|
||||
prefilledName: getLinkedWorkItemSuggestedName(issue)
|
||||
prefilledName: getLinkedWorkItemSuggestedName(issue),
|
||||
telemetrySource: 'sidebar'
|
||||
})
|
||||
},
|
||||
[openModal]
|
||||
@@ -1689,7 +1787,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
// Why: same rationale as handleUseWorkItem — open the New Workspace
|
||||
// dialog pre-filled rather than yolo-creating the worktree, so the
|
||||
// user can confirm name / agent / setup before the worktree lands in
|
||||
// the sidebar.
|
||||
// the sidebar. Telemetry attribution flows via openComposerForLinearItem.
|
||||
openComposerForLinearItem(issue)
|
||||
},
|
||||
[openComposerForLinearItem]
|
||||
@@ -1731,7 +1829,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
paint above our content and receive the click cleanly. */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{/* Why: in workspace view App.tsx suppresses its full-width titlebar,
|
||||
so render a matching 42px strip here to keep the top band
|
||||
so render a matching 36px strip here to keep the top band
|
||||
continuous with the sidebar header and tab rows. When the sidebar
|
||||
is collapsed, App.tsx floats its titlebar-left controls (traffic
|
||||
lights, sidebar toggle, agent badge) over the top-left of this
|
||||
@@ -1744,7 +1842,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
non-workspace mode because App.tsx already owns the top titlebar
|
||||
and a second strip would produce a duplicate band. */}
|
||||
{workspaceActive ? (
|
||||
<div className="flex-none flex h-[42px]">
|
||||
<div className="flex-none flex h-9">
|
||||
{reserveCollapsedHeaderSpace ? (
|
||||
// Why: the floating titlebar-left hosts real interactive chrome
|
||||
// (sidebar-expand toggle, agent badge) under this segment. Both
|
||||
@@ -1771,7 +1869,13 @@ export default function TaskPage(): React.JSX.Element {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mx-auto flex w-full flex-1 flex-col min-h-0 px-5 pt-3 pb-5 md:px-8 md:pt-3 md:pb-7">
|
||||
{/* Why: pt-1.5 vertically centers this row's 32px icon cluster (X +
|
||||
source toggles) with the sidebar's "Tasks" nav row. Sidebar Tasks
|
||||
center sits 22px below the titlebar (pt-2 + py-1.5 + half size-4
|
||||
icon). Matching that here needs 6px top padding above the 32px
|
||||
cluster (6 + 16 = 22). The previous pt-3 placed the cluster 6px
|
||||
too low, breaking the visual band across the top chrome. */}
|
||||
<div className="mx-auto flex w-full flex-1 flex-col min-h-0 px-5 pt-1.5 pb-5 md:px-8 md:pt-1.5 md:pb-7">
|
||||
<div className="flex-none flex flex-col gap-3">
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -1866,7 +1970,10 @@ export default function TaskPage(): React.JSX.Element {
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setGithubMode(mode)}
|
||||
onClick={() => {
|
||||
setGithubMode(mode)
|
||||
setTaskResumeState({ githubMode: mode })
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-1 text-xs transition',
|
||||
active
|
||||
@@ -1925,6 +2032,10 @@ export default function TaskPage(): React.JSX.Element {
|
||||
setTaskSearchInput(query)
|
||||
setAppliedTaskSearch(query)
|
||||
setActiveTaskPreset(option.id)
|
||||
setTaskResumeState({
|
||||
githubItemsPreset: option.id,
|
||||
githubItemsQuery: query
|
||||
})
|
||||
setTaskRefreshNonce((current) => current + 1)
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
@@ -2016,6 +2127,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
setTaskSearchInput('')
|
||||
setAppliedTaskSearch('')
|
||||
setActiveTaskPreset(null)
|
||||
setTaskResumeState({ githubItemsPreset: null, githubItemsQuery: '' })
|
||||
setTaskRefreshNonce((current) => current + 1)
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
|
||||
@@ -2112,6 +2224,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
setLinearSearchInput('')
|
||||
setAppliedLinearSearch('')
|
||||
setActiveLinearPreset(preset.id)
|
||||
setTaskResumeState({ linearPreset: preset.id, linearQuery: '' })
|
||||
setLinearRefreshNonce((n) => n + 1)
|
||||
}}
|
||||
className={cn(
|
||||
@@ -2189,7 +2302,10 @@ export default function TaskPage(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
setAppliedLinearSearch(linearSearchInput.trim())
|
||||
const trimmed = linearSearchInput.trim()
|
||||
setLinearSearchInput(trimmed)
|
||||
setAppliedLinearSearch(trimmed)
|
||||
setTaskResumeState({ linearQuery: trimmed })
|
||||
setLinearRefreshNonce((n) => n + 1)
|
||||
}
|
||||
}}
|
||||
@@ -2203,6 +2319,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
onClick={() => {
|
||||
setLinearSearchInput('')
|
||||
setAppliedLinearSearch('')
|
||||
setTaskResumeState({ linearQuery: '' })
|
||||
setLinearRefreshNonce((n) => n + 1)
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
|
||||
|
||||
@@ -601,7 +601,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
closeModal()
|
||||
// Why: defer opening so Radix fully unmounts the palette's dialog before
|
||||
// the composer modal mounts, avoiding focus churn between the two.
|
||||
queueMicrotask(() => openModal('new-workspace-composer', data))
|
||||
queueMicrotask(() =>
|
||||
openModal('new-workspace-composer', { ...data, telemetrySource: 'command_palette' })
|
||||
)
|
||||
}
|
||||
|
||||
// Case 1: user pasted a GH issue/PR URL.
|
||||
@@ -654,11 +656,16 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
// Fallback: we couldn't resolve the URL, just seed the name.
|
||||
data.prefilledName = `${slug.owner}-${slug.repo}-${number}`
|
||||
}
|
||||
queueMicrotask(() => openModal('new-workspace-composer', data))
|
||||
queueMicrotask(() =>
|
||||
openModal('new-workspace-composer', { ...data, telemetrySource: 'command_palette' })
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
queueMicrotask(() =>
|
||||
openModal('new-workspace-composer', { initialRepoId: repoForLookup.id })
|
||||
openModal('new-workspace-composer', {
|
||||
initialRepoId: repoForLookup.id,
|
||||
telemetrySource: 'command_palette'
|
||||
})
|
||||
)
|
||||
})
|
||||
return
|
||||
@@ -702,13 +709,16 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
} else {
|
||||
data.prefilledName = trimmed
|
||||
}
|
||||
queueMicrotask(() => openModal('new-workspace-composer', data))
|
||||
queueMicrotask(() =>
|
||||
openModal('new-workspace-composer', { ...data, telemetrySource: 'command_palette' })
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
queueMicrotask(() =>
|
||||
openModal('new-workspace-composer', {
|
||||
initialRepoId: repoForLookup.id,
|
||||
prefilledName: trimmed
|
||||
prefilledName: trimmed,
|
||||
telemetrySource: 'command_palette'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -32,9 +32,7 @@ export default function ProjectGroupHeader({
|
||||
<span className="rounded-full border border-border/50 bg-background px-1.5 text-[10px] text-muted-foreground">
|
||||
{group.rows.length}
|
||||
</span>
|
||||
{dateRange ? (
|
||||
<span className="text-[10px] text-muted-foreground">{dateRange}</span>
|
||||
) : null}
|
||||
{dateRange ? <span className="text-[10px] text-muted-foreground">{dateRange}</span> : null}
|
||||
{isCurrent ? (
|
||||
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-1.5 text-[10px] text-emerald-700 dark:text-emerald-300">
|
||||
Current
|
||||
@@ -46,9 +44,10 @@ export default function ProjectGroupHeader({
|
||||
|
||||
function formatDateRange(startDate: string, duration: number): string {
|
||||
const start = new Date(`${startDate}T00:00:00Z`)
|
||||
if (Number.isNaN(start.getTime())) {return ''}
|
||||
if (Number.isNaN(start.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const end = new Date(start.getTime() + (duration - 1) * 86_400_000)
|
||||
const fmt = (d: Date): string =>
|
||||
`${d.getUTCMonth() + 1}/${d.getUTCDate()}`
|
||||
const fmt = (d: Date): string => `${d.getUTCMonth() + 1}/${d.getUTCDate()}`
|
||||
return `${fmt(start)} – ${fmt(end)}`
|
||||
}
|
||||
|
||||
@@ -38,9 +38,7 @@ export default function ProjectItemSlugDialog({
|
||||
<VisuallyHidden.Root asChild>
|
||||
<SheetDescription>Project row preview.</SheetDescription>
|
||||
</VisuallyHidden.Root>
|
||||
{projectOrigin ? (
|
||||
<SlugDialogBody projectOrigin={projectOrigin} onClose={onClose} />
|
||||
) : null}
|
||||
{projectOrigin ? <SlugDialogBody projectOrigin={projectOrigin} onClose={onClose} /> : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
|
||||
@@ -27,9 +27,12 @@ export type ResolvedProjectSelection = {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
activeProject:
|
||||
| { owner: string; ownerType: GitHubProjectOwnerType; number: number; title?: string }
|
||||
| null
|
||||
activeProject: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
number: number
|
||||
title?: string
|
||||
} | null
|
||||
onSelect: (selection: ResolvedProjectSelection) => void
|
||||
}
|
||||
|
||||
@@ -61,9 +64,9 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
// popover and reopening within the 5min window doesn't flicker the
|
||||
// banner back. Populated only when discovery succeeded but a subset of
|
||||
// orgs failed (the 504 path the user reported).
|
||||
const [partialFailures, setPartialFailures] = useState<
|
||||
{ owner: string; message: string }[]
|
||||
>(() => browseCache?.partialFailures ?? [])
|
||||
const [partialFailures, setPartialFailures] = useState<{ owner: string; message: string }[]>(
|
||||
() => browseCache?.partialFailures ?? []
|
||||
)
|
||||
const [pasteInput, setPasteInput] = useState('')
|
||||
const [pasteError, setPasteError] = useState<string | null>(null)
|
||||
const [pasteBusy, setPasteBusy] = useState(false)
|
||||
@@ -133,10 +136,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
number: selection.projectNumber,
|
||||
lastOpenedAt: new Date().toISOString()
|
||||
},
|
||||
...prev.recent.filter(
|
||||
(r) =>
|
||||
`${r.ownerType}:${r.owner}:${r.number}` !== key
|
||||
)
|
||||
...prev.recent.filter((r) => `${r.ownerType}:${r.owner}:${r.number}` !== key)
|
||||
].slice(0, 10)
|
||||
const lastViewByProject = { ...prev.lastViewByProject }
|
||||
if (selection.viewId) {
|
||||
@@ -163,18 +163,16 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
)
|
||||
|
||||
const handleChooseProject = useCallback(
|
||||
async (
|
||||
selection: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
number: number
|
||||
title?: string
|
||||
// Why: when the paste resolver parsed a /views/{n} URL, the caller
|
||||
// passes the view number through so we can skip the view-pick step
|
||||
// and commit directly once listProjectViews returns the matching id.
|
||||
viewNumber?: number
|
||||
}
|
||||
) => {
|
||||
async (selection: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
number: number
|
||||
title?: string
|
||||
// Why: when the paste resolver parsed a /views/{n} URL, the caller
|
||||
// passes the view number through so we can skip the view-pick step
|
||||
// and commit directly once listProjectViews returns the matching id.
|
||||
viewNumber?: number
|
||||
}) => {
|
||||
const key = `${selection.ownerType}:${selection.owner}:${selection.number}`
|
||||
const lastView = projectSettings.lastViewByProject[key]?.viewId
|
||||
// Why: an explicit viewNumber from the URL takes precedence over the
|
||||
@@ -236,9 +234,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
// step with a perpetual spinner. Treat as an empty result and toast
|
||||
// a transport-level message so the user can retry or paste again.
|
||||
setViewList([])
|
||||
toast.error(
|
||||
`Failed to load views: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
toast.error(`Failed to load views: ${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
setViewLoading(false)
|
||||
}
|
||||
@@ -285,8 +281,12 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
)
|
||||
return browseProjects.filter((p) => {
|
||||
const key = `${p.ownerType}:${p.owner}:${p.number}`
|
||||
if (pinnedKeys.has(key) || recentKeys.has(key)) {return false}
|
||||
if (!q) {return true}
|
||||
if (pinnedKeys.has(key) || recentKeys.has(key)) {
|
||||
return false
|
||||
}
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
p.title.toLowerCase().includes(q) ||
|
||||
p.owner.toLowerCase().includes(q) ||
|
||||
@@ -343,8 +343,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
<Section label="Pinned">
|
||||
{projectSettings.pinned.map((p) => {
|
||||
const key = `${p.ownerType}:${p.owner}:${p.number}`
|
||||
const knownGood =
|
||||
projectSettings.lastViewByProject[key]?.viewId != null
|
||||
const knownGood = projectSettings.lastViewByProject[key]?.viewId != null
|
||||
const match = browseProjects.find(
|
||||
(bp) => `${bp.ownerType}:${bp.owner}:${bp.number}` === key
|
||||
)
|
||||
@@ -453,7 +452,9 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
setPasteError(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {void handlePaste()}
|
||||
if (e.key === 'Enter') {
|
||||
void handlePaste()
|
||||
}
|
||||
}}
|
||||
placeholder="Add by URL or owner/number"
|
||||
className="h-8 text-xs"
|
||||
@@ -586,9 +587,7 @@ function ViewPickStep({
|
||||
onClick={() => void onPick(v)}
|
||||
className={cn(
|
||||
'flex w-full flex-col items-start rounded px-2 py-1 text-left',
|
||||
supported
|
||||
? 'hover:bg-muted/50'
|
||||
: 'cursor-not-allowed opacity-50'
|
||||
supported ? 'hover:bg-muted/50' : 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{v.name}</span>
|
||||
@@ -621,7 +620,9 @@ function PartialFailuresBanner({
|
||||
failures.length === 1 && failures[0].owner !== '*'
|
||||
? `Couldn't load projects from ${failures[0].owner}.`
|
||||
: `Some organizations didn't load (${failures.length}).`
|
||||
const detail = failures.map((f) => `${f.owner === '*' ? 'orgs' : f.owner}: ${f.message}`).join('\n')
|
||||
const detail = failures
|
||||
.map((f) => `${f.owner === '*' ? 'orgs' : f.owner}: ${f.message}`)
|
||||
.join('\n')
|
||||
return (
|
||||
<div
|
||||
className="border-b border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
|
||||
@@ -673,7 +674,9 @@ function AuthErrorBanner({ error }: { error: GitHubProjectViewError }): React.JS
|
||||
function parseProjectInput(
|
||||
input: string
|
||||
): { owner: string; number: number; viewNumber?: number } | null {
|
||||
if (!input) {return null}
|
||||
if (!input) {
|
||||
return null
|
||||
}
|
||||
// owner/number
|
||||
const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(input)
|
||||
if (short) {
|
||||
@@ -681,17 +684,23 @@ function parseProjectInput(
|
||||
}
|
||||
try {
|
||||
const url = new URL(input)
|
||||
if (url.hostname !== 'github.com') {return null}
|
||||
if (url.hostname !== 'github.com') {
|
||||
return null
|
||||
}
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
// /orgs/{owner}/projects/{n} or /users/{owner}/projects/{n}[/views/{viewNumber}]
|
||||
if ((parts[0] === 'orgs' || parts[0] === 'users') && parts[2] === 'projects' && parts[3]) {
|
||||
const owner = parts[1]
|
||||
const number = Number(parts[3])
|
||||
if (Number.isNaN(number)) {return null}
|
||||
if (Number.isNaN(number)) {
|
||||
return null
|
||||
}
|
||||
let viewNumber: number | undefined
|
||||
if (parts[4] === 'views' && parts[5]) {
|
||||
const v = Number(parts[5])
|
||||
if (!Number.isNaN(v)) {viewNumber = v}
|
||||
if (!Number.isNaN(v)) {
|
||||
viewNumber = v
|
||||
}
|
||||
}
|
||||
return { owner, number, viewNumber }
|
||||
}
|
||||
|
||||
@@ -5,11 +5,7 @@ import { cn } from '@/lib/utils'
|
||||
import ProjectGroupHeader from './ProjectGroupHeader'
|
||||
import ProjectRow, { buildGridTemplate } from './ProjectRow'
|
||||
import { groupRows, sortRows } from './group-sort'
|
||||
import {
|
||||
getAvailableColumns,
|
||||
loadHiddenColumns,
|
||||
saveHiddenColumns
|
||||
} from './columns'
|
||||
import { getAvailableColumns, loadHiddenColumns, saveHiddenColumns } from './columns'
|
||||
import type {
|
||||
GitHubIssueType,
|
||||
GitHubProjectField,
|
||||
@@ -71,17 +67,24 @@ export default function ProjectViewList({
|
||||
const toggleColumn = (fieldId: string): void => {
|
||||
setHidden((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(fieldId)) {next.delete(fieldId)}
|
||||
else {next.add(fieldId)}
|
||||
if (next.has(fieldId)) {
|
||||
next.delete(fieldId)
|
||||
} else {
|
||||
next.add(fieldId)
|
||||
}
|
||||
saveHiddenColumns(scopeKey, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const effectiveTable = useMemo<GitHubProjectTable>(() => {
|
||||
if (!sortOverride) {return table}
|
||||
if (!sortOverride) {
|
||||
return table
|
||||
}
|
||||
const field = fields.find((f) => f.id === sortOverride.fieldId)
|
||||
if (!field) {return table}
|
||||
if (!field) {
|
||||
return table
|
||||
}
|
||||
return {
|
||||
...table,
|
||||
selectedView: {
|
||||
@@ -101,8 +104,12 @@ export default function ProjectViewList({
|
||||
|
||||
const handleSortClick = (fieldId: string): void => {
|
||||
setSortOverride((prev) => {
|
||||
if (!prev || prev.fieldId !== fieldId) {return { fieldId, direction: 'ASC' }}
|
||||
if (prev.direction === 'ASC') {return { fieldId, direction: 'DESC' }}
|
||||
if (!prev || prev.fieldId !== fieldId) {
|
||||
return { fieldId, direction: 'ASC' }
|
||||
}
|
||||
if (prev.direction === 'ASC') {
|
||||
return { fieldId, direction: 'DESC' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
@@ -148,8 +155,11 @@ export default function ProjectViewList({
|
||||
onToggle={() => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(g.key)) {next.delete(g.key)}
|
||||
else {next.add(g.key)}
|
||||
if (next.has(g.key)) {
|
||||
next.delete(g.key)
|
||||
} else {
|
||||
next.add(g.key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
|
||||
@@ -25,9 +25,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import GitHubItemDialog, {
|
||||
type GitHubItemDialogProjectOrigin
|
||||
} from '@/components/GitHubItemDialog'
|
||||
import GitHubItemDialog, { type GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog'
|
||||
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
|
||||
import { useRepoSlugIndex } from '@/lib/repo-slug-index'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -90,11 +88,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
const [appliedQueryByView, setAppliedQueryByView] = useState<Record<string, string>>({})
|
||||
|
||||
const doFetch = useCallback(
|
||||
async (
|
||||
selection: ResolvedProjectSelection,
|
||||
force = false,
|
||||
queryOverride?: string
|
||||
) => {
|
||||
async (selection: ResolvedProjectSelection, force = false, queryOverride?: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -127,10 +121,14 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
|
||||
// Auto-fetch when activeProject exists and we don't have cached data.
|
||||
useEffect(() => {
|
||||
if (!activeProject) {return}
|
||||
if (!activeProject) {
|
||||
return
|
||||
}
|
||||
const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}`
|
||||
const viewId = lastViewByProject[key]?.viewId
|
||||
if (!viewId) {return}
|
||||
if (!viewId) {
|
||||
return
|
||||
}
|
||||
const projectViewKey = `${key}:${viewId}`
|
||||
const queryOverride = appliedQueryByView[projectViewKey]
|
||||
const cacheKey = projectViewCacheKey(
|
||||
@@ -140,7 +138,9 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
viewId,
|
||||
queryOverride
|
||||
)
|
||||
if (projectViewCache[cacheKey]?.data) {return}
|
||||
if (projectViewCache[cacheKey]?.data) {
|
||||
return
|
||||
}
|
||||
void doFetch(
|
||||
{
|
||||
owner: activeProject.owner,
|
||||
@@ -157,9 +157,13 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
// tab strip can render. The list is small and rarely changes — fetched once
|
||||
// per project per session is fine.
|
||||
useEffect(() => {
|
||||
if (!activeProject) {return}
|
||||
if (!activeProject) {
|
||||
return
|
||||
}
|
||||
const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}`
|
||||
if (viewListByProject[projectKey]) {return}
|
||||
if (viewListByProject[projectKey]) {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void window.api.gh
|
||||
.listProjectViews({
|
||||
@@ -168,7 +172,9 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
projectNumber: activeProject.number
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) {return}
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (res.ok) {
|
||||
setViewListByProject((prev) => ({ ...prev, [projectKey]: res.views }))
|
||||
} else {
|
||||
@@ -176,7 +182,9 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) {return}
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
// Why: an IPC rejection here would surface as an unhandled rejection
|
||||
// and dev-tools red — log and fall back to the empty-tabs UI.
|
||||
console.warn('[project-view] listProjectViews threw:', err)
|
||||
@@ -188,10 +196,14 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
|
||||
const handleSwitchView = useCallback(
|
||||
async (viewId: string) => {
|
||||
if (!activeProject) {return}
|
||||
if (!activeProject) {
|
||||
return
|
||||
}
|
||||
const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}`
|
||||
const current = lastViewByProject[projectKey]?.viewId
|
||||
if (current === viewId) {return}
|
||||
if (current === viewId) {
|
||||
return
|
||||
}
|
||||
// Persist the new view selection so reloads & the picker stay in sync.
|
||||
// Why: read the freshest settings via getState() rather than the closure-
|
||||
// captured `settings` — between callback creation and invocation another
|
||||
@@ -224,10 +236,14 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
)
|
||||
|
||||
const currentProjectViewKey = useMemo(() => {
|
||||
if (!activeProject) {return null}
|
||||
if (!activeProject) {
|
||||
return null
|
||||
}
|
||||
const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}`
|
||||
const viewId = lastViewByProject[key]?.viewId
|
||||
if (!viewId) {return null}
|
||||
if (!viewId) {
|
||||
return null
|
||||
}
|
||||
return `${key}:${viewId}`
|
||||
}, [activeProject, lastViewByProject])
|
||||
|
||||
@@ -236,10 +252,14 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
: undefined
|
||||
|
||||
const currentCacheKey = useMemo(() => {
|
||||
if (!activeProject) {return null}
|
||||
if (!activeProject) {
|
||||
return null
|
||||
}
|
||||
const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}`
|
||||
const viewId = lastViewByProject[key]?.viewId
|
||||
if (!viewId) {return null}
|
||||
if (!viewId) {
|
||||
return null
|
||||
}
|
||||
return projectViewCacheKey(
|
||||
activeProject.ownerType,
|
||||
activeProject.owner,
|
||||
@@ -255,8 +275,12 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
|
||||
// Parent-dropped toast, once per table.
|
||||
useEffect(() => {
|
||||
if (!table || !currentCacheKey || !table.parentFieldDropped) {return}
|
||||
if (parentDroppedToasted.has(currentCacheKey)) {return}
|
||||
if (!table || !currentCacheKey || !table.parentFieldDropped) {
|
||||
return
|
||||
}
|
||||
if (parentDroppedToasted.has(currentCacheKey)) {
|
||||
return
|
||||
}
|
||||
toast.message('Sub-issue data is unavailable for your token.')
|
||||
setParentDroppedToasted((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -294,8 +318,12 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
|
||||
const buildWorkItem = useCallback(
|
||||
(row: GitHubProjectRow, repoId: string): GitHubWorkItem | null => {
|
||||
if (row.itemType !== 'ISSUE' && row.itemType !== 'PULL_REQUEST') {return null}
|
||||
if (row.content.number == null || !row.content.url) {return null}
|
||||
if (row.itemType !== 'ISSUE' && row.itemType !== 'PULL_REQUEST') {
|
||||
return null
|
||||
}
|
||||
if (row.content.number == null || !row.content.url) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: `${row.itemType === 'PULL_REQUEST' ? 'pr' : 'issue'}:${row.content.number}`,
|
||||
type: row.itemType === 'PULL_REQUEST' ? 'pr' : 'issue',
|
||||
@@ -325,10 +353,16 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
cacheKey: string,
|
||||
table: GitHubProjectTable
|
||||
): GitHubItemDialogProjectOrigin | null => {
|
||||
if (row.itemType !== 'ISSUE' && row.itemType !== 'PULL_REQUEST') {return null}
|
||||
if (row.content.number == null || !row.content.repository) {return null}
|
||||
if (row.itemType !== 'ISSUE' && row.itemType !== 'PULL_REQUEST') {
|
||||
return null
|
||||
}
|
||||
if (row.content.number == null || !row.content.repository) {
|
||||
return null
|
||||
}
|
||||
const [owner, repo] = row.content.repository.split('/')
|
||||
if (!owner || !repo) {return null}
|
||||
if (!owner || !repo) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
@@ -344,11 +378,15 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
|
||||
const handleOpenDialog = useCallback(
|
||||
(row: GitHubProjectRow) => {
|
||||
if (!currentCacheKey || !table) {return}
|
||||
if (!currentCacheKey || !table) {
|
||||
return
|
||||
}
|
||||
const origin = buildOrigin(row, currentCacheKey, table)
|
||||
if (!origin) {
|
||||
// Redacted / draft / missing slug — fall back to opening GitHub.
|
||||
if (row.content.url) {void window.api.shell.openUrl(row.content.url)}
|
||||
if (row.content.url) {
|
||||
void window.api.shell.openUrl(row.content.url)
|
||||
}
|
||||
return
|
||||
}
|
||||
const matched = lookupSlug(`${origin.owner}/${origin.repo}`)
|
||||
@@ -367,9 +405,13 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
|
||||
const handleStartWork = useCallback(
|
||||
(row: GitHubProjectRow) => {
|
||||
if (!currentCacheKey || !table) {return}
|
||||
if (!currentCacheKey || !table) {
|
||||
return
|
||||
}
|
||||
const origin = buildOrigin(row, currentCacheKey, table)
|
||||
if (!origin) {return}
|
||||
if (!origin) {
|
||||
return
|
||||
}
|
||||
const matched = lookupSlug(`${origin.owner}/${origin.repo}`)
|
||||
if (!matched) {
|
||||
setRepoNotInOrca({
|
||||
@@ -380,16 +422,22 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
return
|
||||
}
|
||||
const workItem = buildWorkItem(row, matched.id)
|
||||
if (!workItem) {return}
|
||||
if (!workItem) {
|
||||
return
|
||||
}
|
||||
void launchWorkItemDirect({
|
||||
item: workItem,
|
||||
repoId: matched.id,
|
||||
launchSource: 'task_page',
|
||||
telemetrySource: 'sidebar',
|
||||
openModalFallback: () => {
|
||||
// Why: Project mode does not own the new-workspace composer modal.
|
||||
// When `launchWorkItemDirect` wants user input (setupRunPolicy:'ask'
|
||||
// or agent detection fails), fall back to opening the URL so the
|
||||
// user keeps a path forward rather than a silent no-op.
|
||||
if (row.content.url) {void window.api.shell.openUrl(row.content.url)}
|
||||
if (row.content.url) {
|
||||
void window.api.shell.openUrl(row.content.url)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -398,33 +446,45 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
|
||||
const handleEditAssignees = useCallback(
|
||||
async (row: GitHubProjectRow, add: string[], remove: string[]) => {
|
||||
if (!currentCacheKey) {return}
|
||||
if (!currentCacheKey) {
|
||||
return
|
||||
}
|
||||
const res = await patchProjectIssueOrPr(currentCacheKey, row.id, {
|
||||
...(add.length ? { addAssignees: add } : {}),
|
||||
...(remove.length ? { removeAssignees: remove } : {})
|
||||
})
|
||||
if (!res.ok) {toast.error(res.error.message)}
|
||||
if (!res.ok) {
|
||||
toast.error(res.error.message)
|
||||
}
|
||||
},
|
||||
[currentCacheKey, patchProjectIssueOrPr]
|
||||
)
|
||||
|
||||
const handleEditLabels = useCallback(
|
||||
async (row: GitHubProjectRow, add: string[], remove: string[]) => {
|
||||
if (!currentCacheKey) {return}
|
||||
if (!currentCacheKey) {
|
||||
return
|
||||
}
|
||||
const res = await patchProjectIssueOrPr(currentCacheKey, row.id, {
|
||||
...(add.length ? { addLabels: add } : {}),
|
||||
...(remove.length ? { removeLabels: remove } : {})
|
||||
})
|
||||
if (!res.ok) {toast.error(res.error.message)}
|
||||
if (!res.ok) {
|
||||
toast.error(res.error.message)
|
||||
}
|
||||
},
|
||||
[currentCacheKey, patchProjectIssueOrPr]
|
||||
)
|
||||
|
||||
const handleEditIssueType = useCallback(
|
||||
async (row: GitHubProjectRow, issueType: GitHubIssueType | null) => {
|
||||
if (!currentCacheKey) {return}
|
||||
if (!currentCacheKey) {
|
||||
return
|
||||
}
|
||||
const res = await patchProjectRowIssueType(currentCacheKey, row.id, issueType)
|
||||
if (!res.ok) {toast.error(res.error.message)}
|
||||
if (!res.ok) {
|
||||
toast.error(res.error.message)
|
||||
}
|
||||
},
|
||||
[currentCacheKey, patchProjectRowIssueType]
|
||||
)
|
||||
@@ -435,7 +495,9 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
fieldId: string,
|
||||
value: GitHubProjectFieldMutationValue | null
|
||||
) => {
|
||||
if (!currentCacheKey) {return}
|
||||
if (!currentCacheKey) {
|
||||
return
|
||||
}
|
||||
const result =
|
||||
value === null
|
||||
? await clearProjectFieldValue(currentCacheKey, row.id, fieldId)
|
||||
@@ -480,10 +542,14 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
viewFilter={table?.selectedView.filter ?? ''}
|
||||
appliedOverride={appliedQueryByView[currentProjectViewKey]}
|
||||
onApply={(nextOverride) => {
|
||||
if (!activeProject) {return}
|
||||
if (!activeProject) {
|
||||
return
|
||||
}
|
||||
const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}`
|
||||
const viewId = lastViewByProject[key]?.viewId
|
||||
if (!viewId) {return}
|
||||
if (!viewId) {
|
||||
return
|
||||
}
|
||||
setAppliedQueryByView((prev) => {
|
||||
const next = { ...prev }
|
||||
if (nextOverride === undefined) {
|
||||
@@ -530,10 +596,14 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
if (!activeProject || !currentCacheKey) {return}
|
||||
if (!activeProject || !currentCacheKey) {
|
||||
return
|
||||
}
|
||||
const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}`
|
||||
const viewId = lastViewByProject[key]?.viewId
|
||||
if (!viewId) {return}
|
||||
if (!viewId) {
|
||||
return
|
||||
}
|
||||
void doFetch(
|
||||
{
|
||||
owner: activeProject.owner,
|
||||
@@ -557,7 +627,9 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
? (() => {
|
||||
const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}`
|
||||
const views = viewListByProject[projectKey] ?? []
|
||||
if (views.length === 0) {return null}
|
||||
if (views.length === 0) {
|
||||
return null
|
||||
}
|
||||
const activeViewId = lastViewByProject[projectKey]?.viewId ?? null
|
||||
return (
|
||||
<ViewTabStrip
|
||||
@@ -583,7 +655,9 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
error={error.error}
|
||||
totalCount={error.totalCount}
|
||||
onOpenInGitHub={() => {
|
||||
if (selectedViewUrl) {void window.api.shell.openUrl(selectedViewUrl)}
|
||||
if (selectedViewUrl) {
|
||||
void window.api.shell.openUrl(selectedViewUrl)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : table ? (
|
||||
@@ -595,7 +669,9 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
onEditLabels={(row, add, remove) => void handleEditLabels(row, add, remove)}
|
||||
onEditIssueType={(row, issueType) => void handleEditIssueType(row, issueType)}
|
||||
onOpenInBrowser={(row) => {
|
||||
if (row.content.url) {void window.api.shell.openUrl(row.content.url)}
|
||||
if (row.content.url) {
|
||||
void window.api.shell.openUrl(row.content.url)
|
||||
}
|
||||
}}
|
||||
onStartWork={handleStartWork}
|
||||
/>
|
||||
@@ -612,12 +688,18 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
onUse={(item) => {
|
||||
const current = dialogRepoItem
|
||||
setDialogRepoItem(null)
|
||||
if (!current) {return}
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
void launchWorkItemDirect({
|
||||
item,
|
||||
repoId: current.workItem.repoId,
|
||||
launchSource: 'task_page',
|
||||
telemetrySource: 'sidebar',
|
||||
openModalFallback: () => {
|
||||
if (item.url) {void window.api.shell.openUrl(item.url)}
|
||||
if (item.url) {
|
||||
void window.api.shell.openUrl(item.url)
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
@@ -656,7 +738,9 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (repoNotInOrca.url) {void window.api.shell.openUrl(repoNotInOrca.url)}
|
||||
if (repoNotInOrca.url) {
|
||||
void window.api.shell.openUrl(repoNotInOrca.url)
|
||||
}
|
||||
setRepoNotInOrca(null)
|
||||
}}
|
||||
>
|
||||
@@ -724,7 +808,9 @@ function ProjectSearchInput({
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (dirty) {apply(value)}
|
||||
if (dirty) {
|
||||
apply(value)
|
||||
}
|
||||
}}
|
||||
placeholder={viewFilter || 'GitHub search, e.g. assignee:@me is:open'}
|
||||
title={viewFilter ? `View filter: ${viewFilter}` : undefined}
|
||||
@@ -792,7 +878,8 @@ function ViewTabStrip({
|
||||
active
|
||||
? '-mb-px border-border/60 bg-background text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:bg-background/40 hover:text-foreground',
|
||||
!supported && 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-muted-foreground'
|
||||
!supported &&
|
||||
'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
// local visibility filter on top. Persisted in localStorage (not settings)
|
||||
// because it's purely cosmetic per device and would otherwise bloat the
|
||||
// debounced settings write on every checkbox toggle.
|
||||
import type {
|
||||
GitHubProjectField,
|
||||
GitHubProjectView
|
||||
} from '../../../../shared/github-project-types'
|
||||
import type { GitHubProjectField, GitHubProjectView } from '../../../../shared/github-project-types'
|
||||
|
||||
export const TYPE_FIELD_ID = '__type__'
|
||||
export const TYPE_FIELD_DATA_TYPE = '__TYPE__'
|
||||
@@ -24,12 +21,10 @@ export const TYPE_FIELD: GitHubProjectField = {
|
||||
export function getAvailableColumns(view: GitHubProjectView): GitHubProjectField[] {
|
||||
const fields = view.fields
|
||||
const titleIdx = fields.findIndex((f) => f.dataType === 'TITLE')
|
||||
if (titleIdx === -1) {return [TYPE_FIELD, ...fields]}
|
||||
return [
|
||||
...fields.slice(0, titleIdx + 1),
|
||||
TYPE_FIELD,
|
||||
...fields.slice(titleIdx + 1)
|
||||
]
|
||||
if (titleIdx === -1) {
|
||||
return [TYPE_FIELD, ...fields]
|
||||
}
|
||||
return [...fields.slice(0, titleIdx + 1), TYPE_FIELD, ...fields.slice(titleIdx + 1)]
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'orca.githubProject.hiddenColumns'
|
||||
@@ -39,7 +34,9 @@ type HiddenMap = Record<string, string[]>
|
||||
function readMap(): HiddenMap {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) {return {}}
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? (parsed as HiddenMap) : {}
|
||||
} catch {
|
||||
|
||||
@@ -99,10 +99,22 @@ describe('sortRows', () => {
|
||||
})
|
||||
const rows = [
|
||||
makeRow('r2', 1, {
|
||||
F_status: { kind: 'single-select', fieldId: 'F_status', optionId: 'opt_b', name: 'In Progress', color: 'YELLOW' }
|
||||
F_status: {
|
||||
kind: 'single-select',
|
||||
fieldId: 'F_status',
|
||||
optionId: 'opt_b',
|
||||
name: 'In Progress',
|
||||
color: 'YELLOW'
|
||||
}
|
||||
}),
|
||||
makeRow('r1', 0, {
|
||||
F_status: { kind: 'single-select', fieldId: 'F_status', optionId: 'opt_a', name: 'Todo', color: 'GRAY' }
|
||||
F_status: {
|
||||
kind: 'single-select',
|
||||
fieldId: 'F_status',
|
||||
optionId: 'opt_a',
|
||||
name: 'Todo',
|
||||
color: 'GRAY'
|
||||
}
|
||||
})
|
||||
]
|
||||
const sorted = sortRows(makeTable(view, rows), rows)
|
||||
@@ -119,10 +131,22 @@ describe('sortRows', () => {
|
||||
})
|
||||
const rows = [
|
||||
makeRow('rB', 5, {
|
||||
F_status: { kind: 'single-select', fieldId: 'F_status', optionId: 'orphan_2', name: 'Gone', color: 'GRAY' }
|
||||
F_status: {
|
||||
kind: 'single-select',
|
||||
fieldId: 'F_status',
|
||||
optionId: 'orphan_2',
|
||||
name: 'Gone',
|
||||
color: 'GRAY'
|
||||
}
|
||||
}),
|
||||
makeRow('rA', 1, {
|
||||
F_status: { kind: 'single-select', fieldId: 'F_status', optionId: 'orphan_1', name: 'Gone', color: 'GRAY' }
|
||||
F_status: {
|
||||
kind: 'single-select',
|
||||
fieldId: 'F_status',
|
||||
optionId: 'orphan_1',
|
||||
name: 'Gone',
|
||||
color: 'GRAY'
|
||||
}
|
||||
})
|
||||
]
|
||||
const sorted = sortRows(makeTable(view, rows), rows)
|
||||
@@ -169,7 +193,13 @@ describe('sortRows', () => {
|
||||
const rows = [
|
||||
makeRow('rEmpty', 0, {}),
|
||||
makeRow('rHas', 1, {
|
||||
F_status: { kind: 'single-select', fieldId: 'F_status', optionId: 'opt_a', name: 'Todo', color: 'GRAY' }
|
||||
F_status: {
|
||||
kind: 'single-select',
|
||||
fieldId: 'F_status',
|
||||
optionId: 'opt_a',
|
||||
name: 'Todo',
|
||||
color: 'GRAY'
|
||||
}
|
||||
})
|
||||
]
|
||||
const sorted = sortRows(makeTable(view, rows), rows)
|
||||
@@ -186,7 +216,13 @@ describe('groupRows', () => {
|
||||
const rows = [
|
||||
makeRow('rNone', 0, {}),
|
||||
makeRow('rA', 1, {
|
||||
F_status: { kind: 'single-select', fieldId: 'F_status', optionId: 'opt_a', name: 'Todo', color: 'GRAY' }
|
||||
F_status: {
|
||||
kind: 'single-select',
|
||||
fieldId: 'F_status',
|
||||
optionId: 'opt_a',
|
||||
name: 'Todo',
|
||||
color: 'GRAY'
|
||||
}
|
||||
})
|
||||
]
|
||||
const groups = groupRows(makeTable(view, rows), rows)
|
||||
|
||||
@@ -35,7 +35,12 @@ function getFieldValueForGrouping(
|
||||
): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } {
|
||||
const value = row.fieldValuesByFieldId[field.id]
|
||||
if (!value) {
|
||||
return { key: EMPTY_GROUP_KEY, label: labelForEmpty(field), orderHint: UNKNOWN_INDEX_SENTINEL, iteration: null }
|
||||
return {
|
||||
key: EMPTY_GROUP_KEY,
|
||||
label: labelForEmpty(field),
|
||||
orderHint: UNKNOWN_INDEX_SENTINEL,
|
||||
iteration: null
|
||||
}
|
||||
}
|
||||
if (field.kind === 'iteration' && value.kind === 'iteration') {
|
||||
const idx = field.iterations.findIndex((it) => it.id === value.iterationId)
|
||||
@@ -98,7 +103,12 @@ export function groupRows(
|
||||
}
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{ label: string; orderHint: number; iteration: ProjectGroup['iteration']; rows: GitHubProjectRow[] }
|
||||
{
|
||||
label: string
|
||||
orderHint: number
|
||||
iteration: ProjectGroup['iteration']
|
||||
rows: GitHubProjectRow[]
|
||||
}
|
||||
>()
|
||||
for (const row of rowsInOrder) {
|
||||
const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField)
|
||||
@@ -113,8 +123,12 @@ export function groupRows(
|
||||
// Ordering rules per design doc §Grouping.
|
||||
entries.sort((a, b) => {
|
||||
// Empty group always last.
|
||||
if (a[0] === EMPTY_GROUP_KEY) {return 1}
|
||||
if (b[0] === EMPTY_GROUP_KEY) {return -1}
|
||||
if (a[0] === EMPTY_GROUP_KEY) {
|
||||
return 1
|
||||
}
|
||||
if (b[0] === EMPTY_GROUP_KEY) {
|
||||
return -1
|
||||
}
|
||||
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
|
||||
return a[1].orderHint - b[1].orderHint
|
||||
}
|
||||
@@ -133,15 +147,26 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje
|
||||
const aValue = a.fieldValuesByFieldId[field.id]
|
||||
const bValue = b.fieldValuesByFieldId[field.id]
|
||||
// Missing values sort last (regardless of direction).
|
||||
if (!aValue && !bValue) {return 0}
|
||||
if (!aValue) {return 1}
|
||||
if (!bValue) {return -1}
|
||||
if (!aValue && !bValue) {
|
||||
return 0
|
||||
}
|
||||
if (!aValue) {
|
||||
return 1
|
||||
}
|
||||
if (!bValue) {
|
||||
return -1
|
||||
}
|
||||
|
||||
let cmp = 0
|
||||
if (field.kind === 'single-select' && aValue.kind === 'single-select' && bValue.kind === 'single-select') {
|
||||
if (
|
||||
field.kind === 'single-select' &&
|
||||
aValue.kind === 'single-select' &&
|
||||
bValue.kind === 'single-select'
|
||||
) {
|
||||
const aIdx = field.options.findIndex((o) => o.id === aValue.optionId)
|
||||
const bIdx = field.options.findIndex((o) => o.id === bValue.optionId)
|
||||
cmp = (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
|
||||
cmp =
|
||||
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
|
||||
} else if (
|
||||
field.kind === 'iteration' &&
|
||||
aValue.kind === 'iteration' &&
|
||||
@@ -149,7 +174,8 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje
|
||||
) {
|
||||
const aIdx = field.iterations.findIndex((it) => it.id === aValue.iterationId)
|
||||
const bIdx = field.iterations.findIndex((it) => it.id === bValue.iterationId)
|
||||
cmp = (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
|
||||
cmp =
|
||||
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
|
||||
} else if (aValue.kind === 'number' && bValue.kind === 'number') {
|
||||
cmp = aValue.number - bValue.number
|
||||
} else if (aValue.kind === 'date' && bValue.kind === 'date') {
|
||||
@@ -159,17 +185,27 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje
|
||||
} else if (aValue.kind === 'users' && bValue.kind === 'users') {
|
||||
const aLogin = aValue.users[0]?.login ?? ''
|
||||
const bLogin = bValue.users[0]?.login ?? ''
|
||||
if (!aLogin && !bLogin) {cmp = 0}
|
||||
else if (!aLogin) {cmp = 1}
|
||||
else if (!bLogin) {cmp = -1}
|
||||
else {cmp = aLogin.localeCompare(bLogin)}
|
||||
if (!aLogin && !bLogin) {
|
||||
cmp = 0
|
||||
} else if (!aLogin) {
|
||||
cmp = 1
|
||||
} else if (!bLogin) {
|
||||
cmp = -1
|
||||
} else {
|
||||
cmp = aLogin.localeCompare(bLogin)
|
||||
}
|
||||
} else if (aValue.kind === 'labels' && bValue.kind === 'labels') {
|
||||
const aName = aValue.labels[0]?.name ?? ''
|
||||
const bName = bValue.labels[0]?.name ?? ''
|
||||
if (!aName && !bName) {cmp = 0}
|
||||
else if (!aName) {cmp = 1}
|
||||
else if (!bName) {cmp = -1}
|
||||
else {cmp = aName.localeCompare(bName)}
|
||||
if (!aName && !bName) {
|
||||
cmp = 0
|
||||
} else if (!aName) {
|
||||
cmp = 1
|
||||
} else if (!bName) {
|
||||
cmp = -1
|
||||
} else {
|
||||
cmp = aName.localeCompare(bName)
|
||||
}
|
||||
} else {
|
||||
// Why: unknown sort-field kind — ignore this sort field and fall through
|
||||
// to tie-breaks (and eventually row.position). Dev-time warning gated so
|
||||
@@ -183,16 +219,15 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje
|
||||
return sort.direction === 'DESC' ? -cmp : cmp
|
||||
}
|
||||
|
||||
export function sortRows(
|
||||
table: GitHubProjectTable,
|
||||
rows: GitHubProjectRow[]
|
||||
): GitHubProjectRow[] {
|
||||
export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] {
|
||||
const sorts = table.selectedView.sortByFields
|
||||
const out = [...rows]
|
||||
out.sort((a, b) => {
|
||||
for (const sort of sorts) {
|
||||
const cmp = compareSort(a, b, sort)
|
||||
if (cmp !== 0) {return cmp}
|
||||
if (cmp !== 0) {
|
||||
return cmp
|
||||
}
|
||||
}
|
||||
// Final tie-break: row.position preserves GitHub rank order.
|
||||
return a.position - b.position
|
||||
@@ -200,13 +235,12 @@ export function sortRows(
|
||||
return out
|
||||
}
|
||||
|
||||
export function isIterationCurrent(iteration: {
|
||||
startDate: string
|
||||
duration: number
|
||||
}): boolean {
|
||||
export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean {
|
||||
// Parse as YYYY-MM-DD in UTC to avoid TZ-shift false negatives near midnight.
|
||||
const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime()
|
||||
if (Number.isNaN(start)) {return false}
|
||||
if (Number.isNaN(start)) {
|
||||
return false
|
||||
}
|
||||
const end = start + iteration.duration * 86_400_000
|
||||
const now = Date.now()
|
||||
return now >= start && now < end
|
||||
|
||||
@@ -24,7 +24,9 @@ export function AssigneesEditor({
|
||||
// every unrelated re-render while the popover is open.
|
||||
const seedKey = useMemo(() => selected.slice().sort().join(','), [selected])
|
||||
useEffect(() => {
|
||||
if (!open) {return}
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
// Why: guard against late responses overwriting newer state when
|
||||
// owner/repo/seedKey change (or the popover toggles) before the IPC
|
||||
// resolves. Mirrors the requestIdRef pattern used for the details fetch.
|
||||
@@ -37,11 +39,17 @@ export function AssigneesEditor({
|
||||
seedLogins: seedKey ? seedKey.split(',') : []
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) {return}
|
||||
if (res.ok) {setUsers(res.users)}
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (res.ok) {
|
||||
setUsers(res.users)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) {return}
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
@@ -71,8 +79,11 @@ export function AssigneesEditor({
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs hover:bg-muted/50"
|
||||
onClick={() => {
|
||||
if (isOn) {void onChange([], [u.login])}
|
||||
else {void onChange([u.login], [])}
|
||||
if (isOn) {
|
||||
void onChange([], [u.login])
|
||||
} else {
|
||||
void onChange([u.login], [])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -77,7 +77,14 @@ function CommentRow({
|
||||
<div className="mb-1 flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>{comment.author}</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="hover:underline" onClick={() => { setDraft(comment.body); setEditing(true) }}>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
onClick={() => {
|
||||
setDraft(comment.body)
|
||||
setEditing(true)
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" className="hover:underline" onClick={() => void onDelete()}>
|
||||
@@ -142,7 +149,9 @@ export function NewCommentForm({
|
||||
disabled={!draft.trim() || submitting}
|
||||
onClick={async () => {
|
||||
const body = draft.trim()
|
||||
if (!body) {return}
|
||||
if (!body) {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await window.api.gh.addIssueCommentBySlug({ owner, repo, number, body })
|
||||
|
||||
@@ -19,7 +19,9 @@ export function LabelsEditor({
|
||||
const [options, setOptions] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!open) {return}
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
// Why: guard against late responses overwriting newer state when the
|
||||
// popover toggles owner/repo (or closes/reopens) while the IPC is still
|
||||
// in flight. Mirrors the requestIdRef pattern used for the details fetch.
|
||||
@@ -28,11 +30,17 @@ export function LabelsEditor({
|
||||
window.api.gh
|
||||
.listLabelsBySlug({ owner, repo })
|
||||
.then((res) => {
|
||||
if (cancelled) {return}
|
||||
if (res.ok) {setOptions(res.labels)}
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (res.ok) {
|
||||
setOptions(res.labels)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) {return}
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
@@ -62,11 +70,19 @@ export function LabelsEditor({
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs hover:bg-muted/50"
|
||||
onClick={() => {
|
||||
if (isOn) {void onChange([], [name])}
|
||||
else {void onChange([name], [])}
|
||||
if (isOn) {
|
||||
void onChange([], [name])
|
||||
} else {
|
||||
void onChange([name], [])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={cn('inline-block size-2 rounded-full', isOn ? 'bg-primary' : 'bg-muted-foreground/40')} />
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block size-2 rounded-full',
|
||||
isOn ? 'bg-primary' : 'bg-muted-foreground/40'
|
||||
)}
|
||||
/>
|
||||
{name}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -27,10 +27,16 @@ export function SlugDialogBody({
|
||||
// patches applied by the table (e.g. inline assignee edits).
|
||||
const row = useMemo(() => {
|
||||
const table = projectViewCache[cacheKey]?.data
|
||||
if (!table) {return null}
|
||||
return table.rows.find(
|
||||
(r) => r.content.number === number && r.content.repository?.toLowerCase() === `${owner}/${repo}`.toLowerCase()
|
||||
) ?? null
|
||||
if (!table) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
table.rows.find(
|
||||
(r) =>
|
||||
r.content.number === number &&
|
||||
r.content.repository?.toLowerCase() === `${owner}/${repo}`.toLowerCase()
|
||||
) ?? null
|
||||
)
|
||||
}, [projectViewCache, cacheKey, owner, repo, number])
|
||||
|
||||
const [details, setDetails] = useState<GitHubWorkItemDetails | null>(null)
|
||||
@@ -47,7 +53,9 @@ export function SlugDialogBody({
|
||||
window.api.gh
|
||||
.projectWorkItemDetailsBySlug({ owner, repo, number, type })
|
||||
.then((res) => {
|
||||
if (rid !== requestIdRef.current) {return}
|
||||
if (rid !== requestIdRef.current) {
|
||||
return
|
||||
}
|
||||
if (res.ok) {
|
||||
setDetails(res.details)
|
||||
} else {
|
||||
@@ -55,11 +63,15 @@ export function SlugDialogBody({
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (rid !== requestIdRef.current) {return}
|
||||
if (rid !== requestIdRef.current) {
|
||||
return
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Failed to load details')
|
||||
})
|
||||
.finally(() => {
|
||||
if (rid !== requestIdRef.current) {return}
|
||||
if (rid !== requestIdRef.current) {
|
||||
return
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
}, [owner, repo, number, type])
|
||||
@@ -74,13 +86,19 @@ export function SlugDialogBody({
|
||||
const commitTitle = useCallback(async () => {
|
||||
const next = titleDraft.trim()
|
||||
setEditingTitle(false)
|
||||
if (!next || next === title) {return}
|
||||
if (!next || next === title) {
|
||||
return
|
||||
}
|
||||
// Why: without a row id we can't address the project item — the helper
|
||||
// would just return "Row not found" and toast-spam the user. The title
|
||||
// button is also disabled in this case (see render below).
|
||||
if (!row) {return}
|
||||
if (!row) {
|
||||
return
|
||||
}
|
||||
const res = await patchProjectIssueOrPr(cacheKey, row.id, { title: next })
|
||||
if (!res.ok) {toast.error(res.error.message)}
|
||||
if (!res.ok) {
|
||||
toast.error(res.error.message)
|
||||
}
|
||||
}, [titleDraft, title, patchProjectIssueOrPr, cacheKey, row])
|
||||
|
||||
const [editingBody, setEditingBody] = useState(false)
|
||||
@@ -88,10 +106,14 @@ export function SlugDialogBody({
|
||||
const body = details?.body ?? ''
|
||||
const commitBody = useCallback(async () => {
|
||||
setEditingBody(false)
|
||||
if (bodyDraft === body) {return}
|
||||
if (bodyDraft === body) {
|
||||
return
|
||||
}
|
||||
// Why: same reason as commitTitle — bail rather than ask the helper to
|
||||
// patch a missing row. The body button is also disabled when row is null.
|
||||
if (!row) {return}
|
||||
if (!row) {
|
||||
return
|
||||
}
|
||||
const res = await patchProjectIssueOrPr(cacheKey, row.id, { body: bodyDraft })
|
||||
if (!res.ok) {
|
||||
toast.error(res.error.message)
|
||||
@@ -177,12 +199,16 @@ export function SlugDialogBody({
|
||||
onChange={async (add, remove) => {
|
||||
// Why: bail rather than call the helper with an empty id —
|
||||
// see commitTitle above. Trigger is also disabled when !row.
|
||||
if (!row) {return}
|
||||
if (!row) {
|
||||
return
|
||||
}
|
||||
const res = await patchProjectIssueOrPr(cacheKey, row.id, {
|
||||
...(add.length ? { addLabels: add } : {}),
|
||||
...(remove.length ? { removeLabels: remove } : {})
|
||||
})
|
||||
if (!res.ok) {toast.error(res.error.message)}
|
||||
if (!res.ok) {
|
||||
toast.error(res.error.message)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AssigneesEditor
|
||||
@@ -191,12 +217,16 @@ export function SlugDialogBody({
|
||||
selected={assignees}
|
||||
disabled={!row}
|
||||
onChange={async (add, remove) => {
|
||||
if (!row) {return}
|
||||
if (!row) {
|
||||
return
|
||||
}
|
||||
const res = await patchProjectIssueOrPr(cacheKey, row.id, {
|
||||
...(add.length ? { addAssignees: add } : {}),
|
||||
...(remove.length ? { removeAssignees: remove } : {})
|
||||
})
|
||||
if (!res.ok) {toast.error(res.error.message)}
|
||||
if (!res.ok) {
|
||||
toast.error(res.error.message)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -269,9 +299,7 @@ export function SlugDialogBody({
|
||||
owner={owner}
|
||||
repo={repo}
|
||||
number={number}
|
||||
onAdded={(c) =>
|
||||
setDetails((d) => (d ? { ...d, comments: [...d.comments, c] } : d))
|
||||
}
|
||||
onAdded={(c) => setDetails((d) => (d ? { ...d, comments: [...d.comments, c] } : d))}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -190,10 +190,7 @@ export default function GitHubRateLimitPill(): React.JSX.Element | null {
|
||||
<div key={b.key} className="flex items-center justify-between gap-3">
|
||||
<span>{b.description}</span>
|
||||
<span
|
||||
className={cn(
|
||||
t === 'crit' && 'text-red-400',
|
||||
t === 'warn' && 'text-amber-400'
|
||||
)}
|
||||
className={cn(t === 'crit' && 'text-red-400', t === 'warn' && 'text-amber-400')}
|
||||
>
|
||||
{v.remaining} of {v.limit} left · resets in {formatReset(v.resetAt)}
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CircleDot,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
GitBranchPlus,
|
||||
GitPullRequest,
|
||||
Github,
|
||||
LoaderCircle,
|
||||
@@ -86,8 +87,17 @@ const MODES: {
|
||||
{ id: 'text', label: 'Name', Icon: CaseSensitive }
|
||||
]
|
||||
|
||||
const emptyHintByMode: Record<SmartNameMode, string> = {
|
||||
smart: 'Start typing to create a name or find a source.',
|
||||
github: 'Start typing to search GitHub PRs and issues.',
|
||||
branches: 'Start typing to find a branch or create a new one.',
|
||||
linear: 'Start typing to search Linear issues.',
|
||||
text: ''
|
||||
}
|
||||
|
||||
type RowEntry =
|
||||
| { kind: 'use-name'; value: string; name: string }
|
||||
| { kind: 'create-branch'; value: string; name: string }
|
||||
| { kind: 'github'; value: string; item: GitHubWorkItem }
|
||||
| { kind: 'branch'; value: string; refName: string }
|
||||
| { kind: 'linear'; value: string; issue: LinearIssue }
|
||||
@@ -108,17 +118,21 @@ export default function SmartWorkspaceNameField({
|
||||
}: SmartWorkspaceNameFieldProps): React.JSX.Element {
|
||||
const {
|
||||
addRepo,
|
||||
checkLinearConnection,
|
||||
fetchWorkItems,
|
||||
getCachedWorkItems,
|
||||
linearStatus,
|
||||
linearStatusChecked,
|
||||
listLinearIssues,
|
||||
searchLinearIssues
|
||||
} = useAppStore(
|
||||
useShallow((s) => ({
|
||||
addRepo: s.addRepo,
|
||||
checkLinearConnection: s.checkLinearConnection,
|
||||
fetchWorkItems: s.fetchWorkItems,
|
||||
getCachedWorkItems: s.getCachedWorkItems,
|
||||
linearStatus: s.linearStatus,
|
||||
linearStatusChecked: s.linearStatusChecked,
|
||||
listLinearIssues: s.listLinearIssues,
|
||||
searchLinearIssues: s.searchLinearIssues
|
||||
}))
|
||||
@@ -157,6 +171,16 @@ export default function SmartWorkspaceNameField({
|
||||
[inputRef]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Why: the composer can be opened before any other Linear-aware surface
|
||||
// (TaskPage, IntegrationsPane) has had a chance to refresh status, leaving
|
||||
// `linearStatus.connected=false` even when the user is actually connected.
|
||||
// Trigger a check on mount if it hasn't run this session.
|
||||
if (!linearStatusChecked) {
|
||||
void checkLinearConnection()
|
||||
}
|
||||
}, [checkLinearConnection, linearStatusChecked])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedQuery(value), SEARCH_DEBOUNCE_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
@@ -371,9 +395,28 @@ export default function SmartWorkspaceNameField({
|
||||
|
||||
const rows = useMemo<RowEntry[]>(() => {
|
||||
const trimmed = value.trim()
|
||||
const nextRows: RowEntry[] = trimmed
|
||||
? [{ kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }]
|
||||
: []
|
||||
// Why: on the Branches tab the generic "Use … as workspace name" row
|
||||
// reads as off-topic — the user is picking/creating a branch. Swap it
|
||||
// for a branch-creation row that's pinned above existing-branch results
|
||||
// (suppressed when an existing branch matches exactly so we don't offer
|
||||
// to "create" something that already exists).
|
||||
const branchExactMatch = mode === 'branches' && trimmed.length > 0 && branches.includes(trimmed)
|
||||
// Why: the "Use … as workspace name" row only makes sense in Smart
|
||||
// mode, where the user might be typing a free-form name. On dedicated
|
||||
// source tabs (GitHub/Linear/Branches) it's off-topic — the user is
|
||||
// there to pick (or, on Branches, create) a source.
|
||||
const useNameRow: RowEntry | null =
|
||||
trimmed && mode === 'smart'
|
||||
? { kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }
|
||||
: null
|
||||
const createBranchRow: RowEntry | null =
|
||||
trimmed && mode === 'branches' && !branchExactMatch
|
||||
? { kind: 'create-branch', value: `create-branch-${trimmed}`, name: trimmed }
|
||||
: null
|
||||
const nextRows: RowEntry[] = []
|
||||
if (useNameRow) {
|
||||
nextRows.push(useNameRow)
|
||||
}
|
||||
if (mode === 'text') {
|
||||
return nextRows
|
||||
}
|
||||
@@ -387,6 +430,9 @@ export default function SmartWorkspaceNameField({
|
||||
)
|
||||
}
|
||||
if (mode === 'smart' || mode === 'branches') {
|
||||
if (createBranchRow) {
|
||||
nextRows.push(createBranchRow)
|
||||
}
|
||||
nextRows.push(
|
||||
...branches.map((refName) => ({
|
||||
kind: 'branch' as const,
|
||||
@@ -407,20 +453,78 @@ export default function SmartWorkspaceNameField({
|
||||
return nextRows.slice(0, RESULT_LIMIT + 1)
|
||||
}, [branches, githubItems, linearIssues, mode, value])
|
||||
|
||||
useEffect(() => {
|
||||
if (rows.length > 0) {
|
||||
setCommandValue((current) =>
|
||||
rows.some((row) => row.value === current) ? current : rows[0].value
|
||||
)
|
||||
// Why: source rows (GitHub/branches/Linear) are driven by debouncedQuery,
|
||||
// so they're stale until the user pauses typing for SEARCH_DEBOUNCE_MS.
|
||||
// We don't want to filter them out (causes flicker as results appear and
|
||||
// disappear with each keystroke), but we do need to prevent cmdk's Enter
|
||||
// handler from auto-selecting a stale source row. Two cases:
|
||||
// - Smart/Branches: a typed-text row (use-name / create-branch) exists
|
||||
// and is pinned at the top — force the highlight onto it so Enter
|
||||
// commits the typed text instead of a stale issue/PR/branch.
|
||||
// - GitHub/Linear: no typed-text fallback row, so clear the highlight
|
||||
// entirely; the input's Enter handler falls through to onPlainEnter.
|
||||
const isQueryStale = value.trim().length > 0 && debouncedQuery.trim() !== value.trim()
|
||||
|
||||
// Why: when the typed value is unambiguously a source reference — a
|
||||
// GitHub issue/PR shorthand ("#1234"), a github.com issue/pull URL, or a
|
||||
// Linear identifier ("STA-123") — the user is clearly looking up that
|
||||
// specific source rather than naming a workspace. Once a matching row
|
||||
// appears in the results, snap the highlight onto it so Enter picks it
|
||||
// instead of the typed-text fallback.
|
||||
const sourceIntent = useMemo<'github' | 'linear' | null>(() => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
}, [rows])
|
||||
if (/^#\d+$/.test(trimmed) || parseGitHubIssueOrPRLink(trimmed) !== null) {
|
||||
return 'github'
|
||||
}
|
||||
if (/^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(trimmed)) {
|
||||
return 'linear'
|
||||
}
|
||||
return null
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (rows.length === 0) {
|
||||
return
|
||||
}
|
||||
if (isQueryStale) {
|
||||
const typedTextRow = rows.find(
|
||||
(row) => row.kind === 'use-name' || row.kind === 'create-branch'
|
||||
)
|
||||
// No typed-text fallback in this mode (GitHub/Linear): clear the
|
||||
// highlight so cmdk doesn't auto-select a stale source on Enter.
|
||||
setCommandValue(typedTextRow ? typedTextRow.value : '')
|
||||
return
|
||||
}
|
||||
if (sourceIntent === 'github') {
|
||||
const githubRow = rows.find((row) => row.kind === 'github')
|
||||
if (githubRow) {
|
||||
setCommandValue(githubRow.value)
|
||||
return
|
||||
}
|
||||
} else if (sourceIntent === 'linear') {
|
||||
const linearRow = rows.find((row) => row.kind === 'linear')
|
||||
if (linearRow) {
|
||||
setCommandValue(linearRow.value)
|
||||
return
|
||||
}
|
||||
}
|
||||
setCommandValue((current) =>
|
||||
rows.some((row) => row.value === current) ? current : rows[0].value
|
||||
)
|
||||
}, [isQueryStale, rows, sourceIntent])
|
||||
|
||||
const loading = githubLoading || branchesLoading || linearLoading
|
||||
const ActiveInputIcon = mode === 'text' ? CaseSensitive : loading ? LoaderCircle : Search
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(row: RowEntry) => {
|
||||
if (row.kind === 'use-name') {
|
||||
if (row.kind === 'use-name' || row.kind === 'create-branch') {
|
||||
// Why: "create new branch" has no existing ref to base from, so
|
||||
// it follows the same path as a typed name — the workspace's branch
|
||||
// is derived from `name` and `baseBranch` stays unset (default base).
|
||||
onValueChange(row.name)
|
||||
} else if (row.kind === 'github') {
|
||||
onGitHubItemSelect(row.item)
|
||||
@@ -517,6 +621,31 @@ export default function SmartWorkspaceNameField({
|
||||
ref={tabsListRef}
|
||||
variant="line"
|
||||
className="h-7 w-full justify-start gap-4 border-b border-border/40 px-0"
|
||||
onFocusCapture={(event) => {
|
||||
// Why: Radix Tabs uses roving focus and re-applies tabindex=0 to
|
||||
// the active trigger on every render, so we can't keep it out of
|
||||
// the natural Tab order via props or a MutationObserver (race
|
||||
// with React commits). Instead, intercept focus *on entry* into
|
||||
// the tabs list:
|
||||
// - Forward Tab from outside (e.g., Repo combobox) → bounce to
|
||||
// the search input so the segmented control is skipped.
|
||||
// - Shift-Tab from the input → relatedTarget is the input, so
|
||||
// allow focus to land on the active trigger (segmented
|
||||
// control remains reachable in reverse).
|
||||
// - Intra-list focus moves (arrow keys) → relatedTarget is
|
||||
// inside the list; allow.
|
||||
const previous = event.relatedTarget as HTMLElement | null
|
||||
const list = tabsListRef.current
|
||||
const input = localInputRef.current
|
||||
if (!list || !input) {
|
||||
return
|
||||
}
|
||||
if (!previous || previous === input || list.contains(previous)) {
|
||||
return
|
||||
}
|
||||
event.stopPropagation()
|
||||
input.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
{MODES.map(({ id, label, Icon }) => (
|
||||
<TabsTrigger
|
||||
@@ -633,8 +762,13 @@ export default function SmartWorkspaceNameField({
|
||||
if (row) {
|
||||
event.preventDefault()
|
||||
handleSelect(row)
|
||||
return
|
||||
}
|
||||
return
|
||||
// No highlighted row (e.g., stale results in
|
||||
// GitHub/Linear modes where the highlight was
|
||||
// cleared to avoid auto-selecting a stale source).
|
||||
// Fall through to onPlainEnter so the keypress
|
||||
// doesn't feel inert.
|
||||
}
|
||||
onPlainEnter?.()
|
||||
}
|
||||
@@ -687,9 +821,9 @@ export default function SmartWorkspaceNameField({
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{mode === 'linear' && !linearStatus.connected
|
||||
{mode === 'linear' && linearStatusChecked && !linearStatus.connected
|
||||
? 'Connect Linear in Settings to search issues.'
|
||||
: 'Start typing to create a name or find a source.'}
|
||||
: emptyHintByMode[mode]}
|
||||
</div>
|
||||
) : (
|
||||
<CommandGroup className="p-1">
|
||||
@@ -747,6 +881,9 @@ function RowIcon({ row }: { row: RowEntry }): React.JSX.Element {
|
||||
if (row.kind === 'use-name') {
|
||||
return <CaseSensitive className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
if (row.kind === 'create-branch') {
|
||||
return <GitBranchPlus className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
if (row.kind === 'github') {
|
||||
return row.item.type === 'pr' ? (
|
||||
<GitPullRequest className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
@@ -790,6 +927,14 @@ function RowLabel({ row }: { row: RowEntry }): React.JSX.Element {
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'create-branch') {
|
||||
return (
|
||||
<span className="min-w-0 truncate">
|
||||
Create new branch{' '}
|
||||
<span className="font-mono text-[11px] font-medium text-foreground">{row.name}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'github') {
|
||||
return (
|
||||
<span className="min-w-0 truncate">
|
||||
|
||||
@@ -79,12 +79,15 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
? (gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown')
|
||||
: 'unknown'
|
||||
|
||||
// Fetch PR data when the active worktree/branch changes
|
||||
// Fetch PR data when the active worktree/branch changes.
|
||||
// Why: pass linkedPR so worktrees created from a PR (whose new local branch
|
||||
// differs from the PR's head ref) resolve via the number-based fallback.
|
||||
const linkedPR = activeWorktree?.linkedPR ?? null
|
||||
useEffect(() => {
|
||||
if (repo && !isFolder && branch) {
|
||||
void fetchPRForBranch(repo.path, branch)
|
||||
void fetchPRForBranch(repo.path, branch, { linkedPRNumber: linkedPR })
|
||||
}
|
||||
}, [repo, isFolder, branch, fetchPRForBranch])
|
||||
}, [repo, isFolder, branch, linkedPR, fetchPRForBranch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!repo || isFolder || !branch || !pr || pr.mergeable !== 'CONFLICTING') {
|
||||
@@ -102,8 +105,8 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
// them so we don't keep rendering cached branch summaries or empty file
|
||||
// lists from an older payload.
|
||||
conflictSummaryRefreshKeyRef.current = refreshKey
|
||||
void fetchPRForBranch(repo.path, branch, { force: true })
|
||||
}, [repo, isFolder, branch, pr, fetchPRForBranch])
|
||||
void fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR })
|
||||
}, [repo, isFolder, branch, pr, linkedPR, fetchPRForBranch])
|
||||
|
||||
// Fetch checks via cached store method
|
||||
const fetchChecks = useCallback(
|
||||
@@ -232,7 +235,10 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
}
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
const refreshedPR = await fetchPRForBranch(repo.path, branch, { force: true })
|
||||
const refreshedPR = await fetchPRForBranch(repo.path, branch, {
|
||||
force: true,
|
||||
linkedPRNumber: linkedPR
|
||||
})
|
||||
if (refreshedPR) {
|
||||
await Promise.all([
|
||||
fetchChecks({ force: true, prNumberOverride: refreshedPR.number }),
|
||||
@@ -245,7 +251,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}, [repo, branch, fetchPRForBranch, fetchChecks, fetchComments])
|
||||
}, [repo, branch, linkedPR, fetchPRForBranch, fetchChecks, fetchComments])
|
||||
|
||||
const handleStartEdit = useCallback(() => {
|
||||
if (!pr) {
|
||||
@@ -275,13 +281,13 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
})
|
||||
if (ok) {
|
||||
// Re-fetch PR to get updated title
|
||||
await fetchPRForBranch(repo.path, branch, { force: true })
|
||||
await fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR })
|
||||
}
|
||||
} finally {
|
||||
setTitleSaving(false)
|
||||
setEditingTitle(false)
|
||||
}
|
||||
}, [repo, pr, titleDraft, branch, fetchPRForBranch])
|
||||
}, [repo, pr, titleDraft, branch, linkedPR, fetchPRForBranch])
|
||||
|
||||
const handleTitleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -315,9 +321,9 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
// Refresh PR (passed to PRActions)
|
||||
const handleRefreshPR = useCallback(async () => {
|
||||
if (repo && branch) {
|
||||
await fetchPRForBranch(repo.path, branch, { force: true })
|
||||
await fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR })
|
||||
}
|
||||
}, [repo, branch, fetchPRForBranch])
|
||||
}, [repo, branch, linkedPR, fetchPRForBranch])
|
||||
|
||||
// Open PR in browser
|
||||
const handleOpenPR = useCallback(() => {
|
||||
|
||||
@@ -297,6 +297,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
const prCacheKey = activeRepo && branchName ? `${activeRepo.path}::${branchName}` : null
|
||||
const prInfo: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null
|
||||
|
||||
const linkedPR = activeWorktree?.linkedPR ?? null
|
||||
useEffect(() => {
|
||||
if (!isBranchVisible || !activeRepo || isFolder || !branchName || branchName === 'HEAD') {
|
||||
return
|
||||
@@ -305,9 +306,10 @@ function SourceControlInner(): React.JSX.Element {
|
||||
// Why: the Source Control panel renders the branch's PR badge directly.
|
||||
// When a terminal checkout moves this worktree onto a new branch, we need
|
||||
// to fetch that branch's PR immediately instead of waiting for the user to
|
||||
// reselect the worktree or open the separate Checks panel.
|
||||
void fetchPRForBranch(activeRepo.path, branchName)
|
||||
}, [activeRepo, branchName, fetchPRForBranch, isBranchVisible, isFolder])
|
||||
// reselect the worktree or open the separate Checks panel. Pass linkedPR
|
||||
// so create-from-PR worktrees resolve via the number-based fallback.
|
||||
void fetchPRForBranch(activeRepo.path, branchName, { linkedPRNumber: linkedPR })
|
||||
}, [activeRepo, branchName, fetchPRForBranch, isBranchVisible, isFolder, linkedPR])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups = {
|
||||
@@ -1135,7 +1137,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
/>
|
||||
)}
|
||||
|
||||
{(scope === 'all' || scope === 'uncommitted') && (
|
||||
{(scope === 'all' || scope === 'uncommitted') && hasUncommittedEntries && (
|
||||
<CommitArea
|
||||
stagedCount={grouped.staged.length}
|
||||
hasUnresolvedConflicts={unresolvedConflicts.length > 0}
|
||||
|
||||
@@ -59,18 +59,24 @@ const STATUS_BAR_TOGGLES: readonly {
|
||||
'Show the active SSH connection. Only visible once an SSH target is configured.'
|
||||
},
|
||||
{
|
||||
id: 'sessions',
|
||||
title: 'Terminal Sessions',
|
||||
description: 'Show the terminal session count in the status bar.',
|
||||
keywords: ['status bar', 'terminal', 'sessions', 'count', 'pty'],
|
||||
toggleDescription: 'Show the number of active terminal sessions across all workspaces.'
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
title: 'Memory Monitoring',
|
||||
description: 'Show memory and CPU usage in the status bar.',
|
||||
keywords: ['status bar', 'memory', 'ram', 'cpu', 'monitoring', 'usage', 'performance'],
|
||||
toggleDescription: 'Show total memory and CPU usage. Click it to see a per-workspace breakdown.'
|
||||
id: 'resource-usage',
|
||||
title: 'Resource Usage',
|
||||
description: 'Show CPU, memory, and terminal session indicators in the status bar.',
|
||||
keywords: [
|
||||
'status bar',
|
||||
'resource',
|
||||
'usage',
|
||||
'memory',
|
||||
'ram',
|
||||
'cpu',
|
||||
'terminal',
|
||||
'sessions',
|
||||
'pty',
|
||||
'monitoring',
|
||||
'performance'
|
||||
],
|
||||
toggleDescription:
|
||||
'Show CPU, memory, and terminal session counts. Click it for a per-workspace breakdown and daemon controls.'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -20,14 +20,11 @@ import { SearchableSetting } from './SearchableSetting'
|
||||
import { useAppStore } from '../../store'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
|
||||
import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions'
|
||||
|
||||
type ConfirmKind = 'killAll' | 'restart' | 'killOne'
|
||||
type ConfirmKind = 'killOne'
|
||||
|
||||
type PendingConfirm =
|
||||
| { kind: 'killAll' }
|
||||
| { kind: 'restart' }
|
||||
| { kind: 'killOne'; session: PtyManagementSession }
|
||||
| null
|
||||
type PendingConfirm = { kind: 'killOne'; session: PtyManagementSession } | null
|
||||
|
||||
// Why: mirror the status-bar SessionsStatusSegment label style — last two
|
||||
// path segments joined by the platform separator, no ellipsis prefix. This
|
||||
@@ -84,46 +81,16 @@ function getConfirmCopy(confirm: PendingConfirm): {
|
||||
if (!confirm) {
|
||||
return null
|
||||
}
|
||||
switch (confirm.kind) {
|
||||
case 'killAll':
|
||||
return {
|
||||
title: 'Kill all terminal sessions?',
|
||||
description: (
|
||||
<>
|
||||
This force-quits every running terminal pane across all workspaces. Any unsaved work in
|
||||
those sessions is lost. The daemon itself keeps running, and new terminals can be opened
|
||||
immediately. This can't be undone.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Kill all sessions',
|
||||
busyLabel: 'Killing…'
|
||||
}
|
||||
case 'restart':
|
||||
return {
|
||||
title: 'Restart the terminal daemon?',
|
||||
description: (
|
||||
<>
|
||||
Kills every running terminal pane and restarts the daemon process. Panes show
|
||||
“Process exited” and can be reopened immediately. Legacy-protocol sessions
|
||||
from a previous app version are preserved. This can't be undone.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Restart daemon',
|
||||
busyLabel: 'Restarting…'
|
||||
}
|
||||
case 'killOne':
|
||||
return {
|
||||
title: 'Kill this session?',
|
||||
description: (
|
||||
<>
|
||||
Force-quits{' '}
|
||||
<span className="font-medium text-foreground">{confirm.session.sessionId}</span>. Any
|
||||
unsaved work in that pane is lost. This can't be undone.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Kill session',
|
||||
busyLabel: 'Killing…'
|
||||
}
|
||||
return {
|
||||
title: 'Kill this session?',
|
||||
description: (
|
||||
<>
|
||||
Force-quits <span className="font-medium text-foreground">{confirm.session.sessionId}</span>
|
||||
. Any unsaved work in that pane is lost. This can't be undone.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Kill session',
|
||||
busyLabel: 'Killing…'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,48 +205,30 @@ export function ManageSessionsSection(): React.JSX.Element {
|
||||
|
||||
const sessionCount = sessions.length
|
||||
|
||||
const handleKillAll = useCallback(async () => {
|
||||
setBusyKind('killAll')
|
||||
mutationInFlight.current = true
|
||||
optimisticRollback.current = sessions
|
||||
setSessions([])
|
||||
try {
|
||||
const { killedCount, remainingCount } = await window.api.pty.management.killAll()
|
||||
if (remainingCount > 0 && killedCount > 0) {
|
||||
toast.warning(
|
||||
`Killed ${killedCount} of ${killedCount + remainingCount} sessions. ${remainingCount} refused to exit.`
|
||||
)
|
||||
} else if (killedCount > 0) {
|
||||
toast.success(`Killed ${killedCount} session${killedCount === 1 ? '' : 's'}.`)
|
||||
} else if (remainingCount === 0) {
|
||||
toast.info('No sessions running.')
|
||||
} else {
|
||||
toast.error(`${remainingCount} session${remainingCount === 1 ? '' : 's'} refused to exit.`)
|
||||
}
|
||||
mutationInFlight.current = false
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
// Why: the IPC rejected before the daemon could act — restore the list
|
||||
// so the user isn't left staring at a blank table that doesn't reflect
|
||||
// reality.
|
||||
// Why: shared hook owns the daemon-wide restart + kill-all flows so the
|
||||
// status-bar segment and this settings pane can't drift on copy, IPC calls,
|
||||
// or the anti-dismiss guard. We pass lifecycle callbacks to keep the pane's
|
||||
// optimistic setSessions([]) + rollback pattern local to the component.
|
||||
const daemonActions = useDaemonActions({
|
||||
onKillAllStart: () => {
|
||||
mutationInFlight.current = true
|
||||
optimisticRollback.current = sessions
|
||||
setSessions([])
|
||||
},
|
||||
onKillAllError: () => {
|
||||
if (isMounted.current && optimisticRollback.current) {
|
||||
setSessions(optimisticRollback.current)
|
||||
}
|
||||
toast.error('Couldn’t kill sessions.', {
|
||||
description: err instanceof Error ? err.message : undefined
|
||||
})
|
||||
} finally {
|
||||
},
|
||||
onKillAllSettled: () => {
|
||||
optimisticRollback.current = null
|
||||
mutationInFlight.current = false
|
||||
if (isMounted.current) {
|
||||
setBusyKind(null)
|
||||
// Why: the dialog stays open during the mutation so the user sees the
|
||||
// spinner + busyLabel and can't dismiss mid-flight (see Dialog guard
|
||||
// below). Close it here when the op actually finishes.
|
||||
setConfirm(null)
|
||||
}
|
||||
void refresh()
|
||||
},
|
||||
onRestartSettled: () => {
|
||||
void refresh()
|
||||
}
|
||||
}, [refresh, sessions])
|
||||
})
|
||||
|
||||
const handleKillOne = useCallback(
|
||||
async (session: PtyManagementSession) => {
|
||||
@@ -311,54 +260,19 @@ export function ManageSessionsSection(): React.JSX.Element {
|
||||
[refresh]
|
||||
)
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setBusyKind('restart')
|
||||
mutationInFlight.current = true
|
||||
try {
|
||||
const { success } = await window.api.pty.management.restart()
|
||||
if (success) {
|
||||
toast.success('Daemon restarted.')
|
||||
} else {
|
||||
toast.error('Restart failed — check logs.')
|
||||
}
|
||||
mutationInFlight.current = false
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error('Restart failed.', {
|
||||
description: err instanceof Error ? err.message : undefined
|
||||
})
|
||||
} finally {
|
||||
mutationInFlight.current = false
|
||||
if (isMounted.current) {
|
||||
setBusyKind(null)
|
||||
setConfirm(null)
|
||||
}
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
// Why: do NOT clear `confirm` here — the dialog must stay open for the
|
||||
// duration of the mutation so the spinner + busyLabel render and the
|
||||
// anti-dismiss guard can actually hold. Each handler's `finally` clears
|
||||
// anti-dismiss guard can actually hold. handleKillOne's `finally` clears
|
||||
// `confirm` when the op resolves.
|
||||
const runConfirmed = useCallback(() => {
|
||||
if (!confirm) {
|
||||
return
|
||||
}
|
||||
const pending = confirm
|
||||
switch (pending.kind) {
|
||||
case 'killAll':
|
||||
void handleKillAll()
|
||||
return
|
||||
case 'restart':
|
||||
void handleRestart()
|
||||
return
|
||||
case 'killOne':
|
||||
void handleKillOne(pending.session)
|
||||
}
|
||||
}, [confirm, handleKillAll, handleKillOne, handleRestart])
|
||||
void handleKillOne(confirm.session)
|
||||
}, [confirm, handleKillOne])
|
||||
|
||||
const copy = useMemo(() => getConfirmCopy(confirm), [confirm])
|
||||
const isBusy = busyKind !== null
|
||||
const isBusy = busyKind !== null || daemonActions.isBusy
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
@@ -413,11 +327,11 @@ export function ManageSessionsSection(): React.JSX.Element {
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={isBusy || sessionCount === 0}
|
||||
onClick={() => setConfirm({ kind: 'killAll' })}
|
||||
onClick={() => daemonActions.setPending('killAll')}
|
||||
aria-label="Kill all sessions"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{busyKind === 'killAll' ? (
|
||||
{daemonActions.busyKind === 'killAll' ? (
|
||||
<LoaderCircle className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 />
|
||||
@@ -434,11 +348,11 @@ export function ManageSessionsSection(): React.JSX.Element {
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={isBusy}
|
||||
onClick={() => setConfirm({ kind: 'restart' })}
|
||||
onClick={() => daemonActions.setPending('restart')}
|
||||
aria-label="Restart daemon"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{busyKind === 'restart' ? (
|
||||
{daemonActions.busyKind === 'restart' ? (
|
||||
<LoaderCircle className="animate-spin" />
|
||||
) : (
|
||||
<RotateCw />
|
||||
@@ -584,6 +498,7 @@ export function ManageSessionsSection(): React.JSX.Element {
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DaemonActionDialog api={daemonActions} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Maximize2, RefreshCw, Trash2, Wifi } from 'lucide-react'
|
||||
import { Check, Copy, Maximize2, RefreshCw, Trash2, Wifi } from 'lucide-react'
|
||||
import { Button } from '../ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
@@ -38,12 +38,14 @@ type NetworkInterface = {
|
||||
|
||||
export function MobilePane(): React.JSX.Element {
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
|
||||
const [pairingUrl, setPairingUrl] = useState<string | null>(null)
|
||||
const [endpoint, setEndpoint] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [devices, setDevices] = useState<PairedDevice[]>([])
|
||||
const [qrEnlarged, setQrEnlarged] = useState(false)
|
||||
const [networkInterfaces, setNetworkInterfaces] = useState<NetworkInterface[]>([])
|
||||
const [selectedAddress, setSelectedAddress] = useState<string | undefined>(undefined)
|
||||
const [codeCopied, setCodeCopied] = useState(false)
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try {
|
||||
@@ -74,7 +76,9 @@ export function MobilePane(): React.JSX.Element {
|
||||
)
|
||||
if (result.available) {
|
||||
setQrDataUrl(result.qrDataUrl)
|
||||
setPairingUrl(result.pairingUrl)
|
||||
setEndpoint(result.endpoint)
|
||||
setCodeCopied(false)
|
||||
void loadDevices()
|
||||
} else {
|
||||
toast.error('WebSocket transport is not running')
|
||||
@@ -110,6 +114,22 @@ export function MobilePane(): React.JSX.Element {
|
||||
return () => clearInterval(interval)
|
||||
}, [deviceCountAtQr, devices.length, loadDevices])
|
||||
|
||||
async function copyPairingCode() {
|
||||
if (!pairingUrl) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: Electron renderer's navigator.clipboard fails in some contexts
|
||||
// (no transient activation, non-secure context). Use the main-process
|
||||
// IPC clipboard which the rest of the app uses everywhere.
|
||||
await window.api.ui.writeClipboardText(pairingUrl)
|
||||
setCodeCopied(true)
|
||||
setTimeout(() => setCodeCopied(false), 2000)
|
||||
} catch {
|
||||
toast.error('Failed to copy pairing code')
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeDevice(deviceId: string) {
|
||||
try {
|
||||
await window.api.mobile.revokeDevice({ deviceId })
|
||||
@@ -177,6 +197,26 @@ export function MobilePane(): React.JSX.Element {
|
||||
<p className="text-muted-foreground max-w-xs text-center text-xs">
|
||||
Scan this code with the Orca mobile app. Each code creates a unique device token.
|
||||
</p>
|
||||
{pairingUrl && (
|
||||
<div className="flex w-full max-w-lg flex-col gap-1.5 px-4">
|
||||
<div className="text-muted-foreground text-center text-xs">
|
||||
Or paste this code in the mobile app:
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void copyPairingCode()}
|
||||
className="font-mono text-[11px] leading-tight whitespace-normal break-all h-auto py-2 px-3"
|
||||
>
|
||||
<span className="flex-1 text-left">{pairingUrl}</span>
|
||||
{codeCopied ? (
|
||||
<Check className="ml-2 size-3.5 shrink-0 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="ml-2 size-3.5 shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { LoaderCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
|
||||
export type DaemonActionKind = 'restart' | 'killAll'
|
||||
|
||||
export type DaemonActionCallbacks = {
|
||||
// Why: ManageSessionsSection owns an optimistic setSessions([]) + rollback
|
||||
// pattern. Exposing lifecycle hooks lets each caller keep the state that
|
||||
// belongs to it (the settings pane's list; the status bar's badge) instead
|
||||
// of pulling unrelated concerns into this module.
|
||||
onKillAllStart?: () => void
|
||||
onKillAllError?: () => void
|
||||
onKillAllSettled?: () => void
|
||||
onRestartSettled?: () => void
|
||||
}
|
||||
|
||||
type PendingConfirm = DaemonActionKind | null
|
||||
|
||||
export type DaemonActionsApi = {
|
||||
pending: PendingConfirm
|
||||
setPending: (kind: PendingConfirm) => void
|
||||
busyKind: DaemonActionKind | null
|
||||
isBusy: boolean
|
||||
runRestart: () => Promise<void>
|
||||
runKillAll: () => Promise<void>
|
||||
runConfirmed: () => void
|
||||
}
|
||||
|
||||
export function useDaemonActions(callbacks?: DaemonActionCallbacks): DaemonActionsApi {
|
||||
const [pending, setPending] = useState<PendingConfirm>(null)
|
||||
const [busyKind, setBusyKind] = useState<DaemonActionKind | null>(null)
|
||||
|
||||
const runRestart = useCallback(async () => {
|
||||
setBusyKind('restart')
|
||||
try {
|
||||
const { success } = await window.api.pty.management.restart()
|
||||
if (success) {
|
||||
toast.success('Daemon restarted.')
|
||||
} else {
|
||||
toast.error('Restart failed — check logs.')
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Restart failed.', {
|
||||
description: err instanceof Error ? err.message : undefined
|
||||
})
|
||||
} finally {
|
||||
setBusyKind(null)
|
||||
setPending(null)
|
||||
callbacks?.onRestartSettled?.()
|
||||
}
|
||||
}, [callbacks])
|
||||
|
||||
const runKillAll = useCallback(async () => {
|
||||
setBusyKind('killAll')
|
||||
callbacks?.onKillAllStart?.()
|
||||
try {
|
||||
const { killedCount, remainingCount } = await window.api.pty.management.killAll()
|
||||
if (remainingCount > 0 && killedCount > 0) {
|
||||
toast.warning(
|
||||
`Killed ${killedCount} of ${killedCount + remainingCount} sessions. ${remainingCount} refused to exit.`
|
||||
)
|
||||
} else if (killedCount > 0) {
|
||||
toast.success(`Killed ${killedCount} session${killedCount === 1 ? '' : 's'}.`)
|
||||
} else if (remainingCount === 0) {
|
||||
toast.info('No sessions running.')
|
||||
} else {
|
||||
toast.error(`${remainingCount} session${remainingCount === 1 ? '' : 's'} refused to exit.`)
|
||||
}
|
||||
} catch (err) {
|
||||
callbacks?.onKillAllError?.()
|
||||
toast.error('Couldn’t kill sessions.', {
|
||||
description: err instanceof Error ? err.message : undefined
|
||||
})
|
||||
} finally {
|
||||
setBusyKind(null)
|
||||
setPending(null)
|
||||
callbacks?.onKillAllSettled?.()
|
||||
}
|
||||
}, [callbacks])
|
||||
|
||||
const runConfirmed = useCallback(() => {
|
||||
if (pending === 'restart') {
|
||||
void runRestart()
|
||||
} else if (pending === 'killAll') {
|
||||
void runKillAll()
|
||||
}
|
||||
}, [pending, runRestart, runKillAll])
|
||||
|
||||
return {
|
||||
pending,
|
||||
setPending,
|
||||
busyKind,
|
||||
isBusy: busyKind !== null,
|
||||
runRestart,
|
||||
runKillAll,
|
||||
runConfirmed
|
||||
}
|
||||
}
|
||||
|
||||
type CopyShape = {
|
||||
title: string
|
||||
description: React.ReactNode
|
||||
confirmLabel: string
|
||||
busyLabel: string
|
||||
}
|
||||
|
||||
function getCopy(kind: DaemonActionKind): CopyShape {
|
||||
if (kind === 'restart') {
|
||||
return {
|
||||
title: 'Restart the terminal daemon?',
|
||||
description: (
|
||||
<>
|
||||
Kills every running terminal pane and restarts the daemon process. Panes show
|
||||
“Process exited” and can be reopened immediately. Legacy-protocol sessions
|
||||
from a previous app version are preserved. This can't be undone.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Restart daemon',
|
||||
busyLabel: 'Restarting…'
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: 'Kill all terminal sessions?',
|
||||
description: (
|
||||
<>
|
||||
This force-quits every running terminal pane across all workspaces. Any unsaved work in
|
||||
those sessions is lost. The daemon itself keeps running, and new terminals can be opened
|
||||
immediately. This can't be undone.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Kill all sessions',
|
||||
busyLabel: 'Killing…'
|
||||
}
|
||||
}
|
||||
|
||||
export function DaemonActionDialog({
|
||||
api,
|
||||
// Why: when mounted under a Popover, we need the confirm to stay open while
|
||||
// the mutation runs. The caller wires `onOpenChange` here to gate dismissal.
|
||||
extraDescription
|
||||
}: {
|
||||
api: DaemonActionsApi
|
||||
extraDescription?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const { pending, setPending, busyKind, isBusy, runConfirmed } = api
|
||||
const copy = pending ? getCopy(pending) : null
|
||||
return (
|
||||
<Dialog
|
||||
open={pending !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
return
|
||||
}
|
||||
if (isBusy) {
|
||||
return
|
||||
}
|
||||
setPending(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
showCloseButton={!isBusy}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isBusy) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (isBusy) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copy ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{copy.title}</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{copy.description}
|
||||
{extraDescription ? <div className="mt-2">{extraDescription}</div> : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPending(null)} disabled={isBusy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={runConfirmed} disabled={isBusy}>
|
||||
{isBusy ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
{isBusy && busyKind === pending ? copy.busyLabel : copy.confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -205,7 +205,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
// the first focus frame from the composer's prompt textarea.
|
||||
closeModal()
|
||||
setTimeout(() => {
|
||||
openModal('new-workspace-composer', { initialRepoId: repoId })
|
||||
openModal('new-workspace-composer', { initialRepoId: repoId, telemetrySource: 'sidebar' })
|
||||
}, 150)
|
||||
}, [closeModal, openModal, repoId])
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
||||
if (!canCreateWorktree) {
|
||||
return
|
||||
}
|
||||
openModal('new-workspace-composer')
|
||||
openModal('new-workspace-composer', { telemetrySource: 'sidebar' })
|
||||
}}
|
||||
aria-label="New workspace"
|
||||
disabled={!canCreateWorktree}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user