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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# mobile: rename entry points — tray row has NO rename action; use history row or detail header

Symptom: an E2E flow that long-presses the "Active now" tray row to rename gets an action sheet with
only `Copy session ID` / `Cancel` — no `Rename` — and stalls waiting for it.

Cause: by design `RemoteSessionRow` (tray) offers only copy-id; `canManage`/rename exists on the
HISTORY row (`session-row.tsx`, sheet: Copy session ID / Rename / Delete session / Cancel) and on the
session-detail header (pressable title, a11y label `Rename session: <title>`, opens RenameModal).
A live session is hoisted out of history into the tray, so while it is live its only row is the
non-renameable tray row. To rename a live session on device: tray row tap → detail → tap title.

Fix (flow notes, verified on iOS 26.5 simulator):
- History row: `longPressOn: '<title>(.)*'` → tap `Rename` → native Alert.prompt: `eraseText: 40`
then `inputText: '<new>'`, VERIFY the field value via hierarchy before tapping the alert's
`Rename` button (this machine's inputText appends/doubles on non-empty fields; erase-first worked
on both the native prompt and the RN RenameModal field `Session name`).
- Detail header: tap `Rename session:(.)*` → field a11y `Session name` → same erase+input+verify →
tap `Save`. Tap target `Rename` regex is full-string: `Rename session: <title>` will not match a
bare `Rename` pattern — the sheet option and the alert button both match `Rename` exactly.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# mobile: session-ingest log file stays empty — use dev:capture or tmux pipe-pane for the socket tail

Symptom: `dev/logs/cloudflare-session-ingest.log` is 0 bytes for the whole run, so `tail -f` on it
shows nothing, while the service is clearly logging (connection and heartbeat lines exist).

Cause: this worktree's dev runner writes service output to the tmux pane, not the log file (observed
2026-07-28 on sessions-context-d669; `pnpm dev:capture cloudflare-session-ingest` had all output).

Fix: for one-shot reads use `pnpm dev:capture cloudflare-session-ingest` (runbook-prescribed). For a
continuous capture across a UI interaction (e.g. the context-switch socket-lease check), pipe the
pane: resolve the window with `tmux list-windows -t kilo-dev-<slug>` (cloudflare-session-ingest was
window 12), then `tmux pipe-pane -t kilo-dev-<slug>:12.0 -o "cat >> '<scratch>/ingest-tail.log'"`.
`#{pane_pipe}` = 1 while active. Toggle it off with the same command at cleanup. Record byte offsets
(`wc -c`) at interaction markers to bracket log segments; the wrangler lines carry no timestamps.
15 changes: 4 additions & 11 deletions apps/mobile/src/components/agents/remote-session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,10 @@ export function RemoteSessionRow({

// Spoken meta mirrors the visible meta the row renders. When `needsInput`
// wins, the right eyebrow shows `NEEDS INPUT` and meta is NOT rendered,
// so the label omits it. The remote row's `live` eyebrow (`live-and-meta`)
// renders the timestamp when `updatedAt` is present, otherwise the
// uppercased status. For speech we expand the timestamp via
// `formatSpokenTimeAgo` and lowercase/underscore-strip the status so
// VoiceOver doesn't read it letter-by-letter.
let spokenMeta: string | null = null;
if (!needsInput) {
spokenMeta = session.updatedAt
? formatSpokenTimeAgo(session.updatedAt)
: session.status.toLowerCase().replaceAll('_', ' ');
}
// so the label omits it. Otherwise announce the relative timestamp only
// when `updatedAt` is present — never the CLI status (same as `remoteMeta`).
const spokenMeta =
!needsInput && session.updatedAt ? formatSpokenTimeAgo(session.updatedAt) : null;

const { iconKind: platformIconKind, spokenPlatform } = selectRowPlatformPresentation({
platform: session.createdOnPlatform,
Expand Down
27 changes: 19 additions & 8 deletions apps/mobile/src/components/agents/session-list-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,15 +229,26 @@ describe('remoteAgentLabel', () => {
});

describe('remoteMeta', () => {
it('returns the uppercased relative time when updatedAt is present', () => {
it('returns the same relative-time string as formatMeta when updatedAt is present', () => {
const updatedAt = '2024-01-01T00:00:00.000Z';
const expected = timeAgo(parseTimestamp(updatedAt)).toUpperCase();
expect(remoteMeta({ status: 'running', updatedAt })).toBe(expected);
});

it('returns the uppercased status when updatedAt is absent', () => {
expect(remoteMeta({ status: 'running' })).toBe('RUNNING');
expect(remoteMeta({ status: 'needs_input' })).toBe('NEEDS_INPUT');
expect(remoteMeta({ updatedAt })).toBe(formatMeta(updatedAt));
expect(remoteMeta({ updatedAt })).toBe(timeAgo(parseTimestamp(updatedAt)).toUpperCase());
});

it('returns undefined when updatedAt is absent (never idle/busy/retry status words)', () => {
// Former fallback uppercased session.status into the timestamp slot
// (BUSY/IDLE/RETRY). Status is no longer a parameter; assert undefined
// for each status that used to leak, plus a row with no status field.
// Extra `status` fields stay on the object so a regression that re-reads
// `.status` would still see them — remoteMeta must ignore them.
const idleRow: { updatedAt?: string; status?: string } = { status: 'idle' };
const busyRow: { updatedAt?: string; status?: string } = { status: 'busy' };
const retryRow: { updatedAt?: string; status?: string } = { status: 'retry' };
const noStatusRow: { updatedAt?: string } = {};
expect(remoteMeta(idleRow)).toBeUndefined();
expect(remoteMeta(busyRow)).toBeUndefined();
expect(remoteMeta(retryRow)).toBeUndefined();
expect(remoteMeta(noStatusRow)).toBeUndefined();
});
});

Expand Down
11 changes: 6 additions & 5 deletions apps/mobile/src/components/agents/session-list-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,13 @@ export function remoteAgentLabel(createdOnPlatform: string | undefined): string
}

/**
* Pinned-tray meta line for an active session. Mirrors `formatMeta` when an
* `updatedAt` timestamp is available, otherwise falls back to the uppercased
* status string (matches the legacy RemoteSessionRow behavior).
* Pinned-tray meta line for an active session. Returns the relative timestamp
* when `updatedAt` is present; otherwise `undefined` so `SessionRow` renders
* the live dot alone. Never the CLI status — status words in the timestamp
* slot were a defect (BUSY/IDLE/RETRY).
*/
export function remoteMeta(session: { status: string; updatedAt?: string }): string {
return session.updatedAt ? formatMeta(session.updatedAt) : session.status.toUpperCase();
export function remoteMeta(session: { updatedAt?: string }): string | undefined {
return session.updatedAt ? formatMeta(session.updatedAt) : undefined;
}

/**
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/components/home/home-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function HomeScreen() {
const isFocused = useIsFocused();
const [refreshing, setRefreshing] = useState(false);

const { organizationId } = useOrganization();
const { organizationId, isLoaded: orgLoaded } = useOrganization();

const invalidateHomeQueries = useCallback(() => {
void queryClient.invalidateQueries({
Expand Down Expand Up @@ -97,9 +97,10 @@ export function HomeScreen() {
refetch: refetchSessions,
} = useAgentSessions({
organizationId,
enabled: orgLoaded,
});

const isLoading = instancesPending || sessionsLoading;
const isLoading = instancesPending || sessionsLoading || !orgLoaded;

// Match what the Home Agent-sessions section actually renders (cloud-agent
// stored + any active), so a CLI-only account shows the first-use promo
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/components/share/share-destinations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export type ShareDestinationRow = StoredSession & {

/**
* Derive the share-gate destination list from the org-scoped stored page.
* `activeSessionIds` is used only to mark and hoist live rows — never as a
* source of rows (activeSessions.list has no organizationId filter).
* The tray query is context-filtered; `activeSessionIds` is still used only
* to mark and hoist live rows — never as a source of rows.
*/
export function selectShareDestinations(
storedSessions: readonly StoredSession[],
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/share/share-gate-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function ShareGateSheet({ shareId }: Readonly<ShareGateSheetProps>) {
const trpc = useTRPC();
const { organizationId, isLoaded: orgLoaded } = useOrganization();
// Org-scoped stored page only (cloud-agent + cli). Active list is an
// id/capability lookup — never a row source (no organizationId filter).
// id/capability lookup — never a row source.
const sessions = useAgentSessions({
createdOnPlatform: expandPlatformFilter(['cloud-agent', 'cli']),
organizationId,
Expand Down
53 changes: 33 additions & 20 deletions apps/mobile/src/lib/active-sessions-live-sync-mount.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,54 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo } from 'react';
import { type QueryFunction, useQueryClient } from '@tanstack/react-query';

import { useUserWebConnection } from '@/components/agents/user-web-connection-provider';
import { ActiveSessionsLiveSync } from '@/lib/active-sessions-live-sync';
import { type CachedActiveSessionsData } from '@/lib/active-sessions-live';
import { buildActiveSessionsInput } from '@/lib/agent-session-input';
import { useOrganization } from '@/lib/organization-context';
import { useTRPC } from '@/lib/trpc';

/**
* React entry point for the active-sessions live-sync owner. Mounts an
* `ActiveSessionsLiveSync` instance exactly once per provider lifetime.
* React entry point for the active-sessions live-sync owner. Holds a standing
* socket lease for the mount lifetime and recreates the per-context owner when
* the selected personal/org context changes.
*/
function useActiveSessionsLiveSync(): void {
const connection = useUserWebConnection();
const queryClient = useQueryClient();
const trpc = useTRPC();
const queryKey = useMemo(() => trpc.activeSessions.list.queryKey(), [trpc]);
// `trpc.activeSessions.list.queryOptions()` returns a fresh object on
// every call; we want a stable queryFn per provider lifetime so the
// live-sync owner can call it via fetchQuery.
const { organizationId, isLoaded } = useOrganization();

// The per-context owner below is recreated on every context switch, and its
// detach()/attach() pair would drop the retain count to zero in between —
// which stops the shared user-web socket. This lease spans the mount's whole
// lifetime, so a context switch swaps owners without ever closing the socket,
// and the socket still comes up before the organization context has loaded,
// exactly as it does today. `retain()` returns its own release function.
useEffect(() => connection.retain(), [connection]);

const input = useMemo(() => buildActiveSessionsInput(organizationId), [organizationId]);
const queryKey = useMemo(() => trpc.activeSessions.list.queryKey(input), [trpc, input]);
const queryFn = useMemo(
() =>
trpc.activeSessions.list.queryOptions().queryFn as QueryFunction<CachedActiveSessionsData>,
[trpc]
trpc.activeSessions.list.queryOptions(input)
.queryFn as QueryFunction<CachedActiveSessionsData>,
[trpc, input]
);

const syncRef = useRef<ActiveSessionsLiveSync | null>(null);
syncRef.current ??= new ActiveSessionsLiveSync({
connection,
queryClient,
queryKey,
queryFn,
});

// One owner per context: the tray cache key varies with the selected
// personal/org context, so switching context has to retarget the WS writes. A
// fresh instance per key is simpler than mutating a live owner's key, and
// `attach()`'s cleanup releases the previous retain and listeners. Gated on
// `isLoaded` so the pre-load default (personal) never claims the socket for a
// context the user has not actually selected.
useEffect(() => {
const sync = syncRef.current;
return sync ? sync.attach() : undefined;
}, []);
if (!isLoaded) {
return undefined;
}
const sync = new ActiveSessionsLiveSync({ connection, queryClient, queryKey, queryFn });
return sync.attach();
}, [connection, isLoaded, queryClient, queryFn, queryKey]);
}

/**
Expand Down
Loading
Loading