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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/ios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ The app holds one WebSocket to the gateway, the collections socket (`docs/st3/cl

The fifteen-second collections opening deadline includes the WebSocket handshake, not just creation of its carrier. The receive-heartbeat deadline starts after the handshake; only a base-window snapshot marks the feed live and resets retry backoff. Opening attempts close their pending carrier when timed out, superseded, or backgrounded. Native socket failures retain only the NSError domain and numeric code, never its localized message. Transient iOS `inactive` states such as the Share sheet preserve the previous foreground state: a live feed keeps receiving and retrying, while a backgrounded feed waits for `active` before resuming.

Commands use this same session-scoped socket only when Expo extra `testClientMode` is exactly `true`, `phone-commands` v1 is granted, and the saved test profile binds the current credential, device key and session. Missing test mode means the normal app: no commands subscription. Each command requires its generated `control_grant` with matching device/session/grant/run/issuer, verb/screen, optional item ID and active windows. Commands are single-use and bounded by the grant budget; disconnect/revoke/disposal cancels in-flight results. The generated typed idle grant lifecycle is still unavailable: the indicator stays hidden pending that contract, not inferred from a command. Native signing fails closed without WebCrypto until codegen accepts an Expo SHA-256 callback; no crypto polyfill is installed.

The local v1 policy permits only inspect and navigation to `NowRoot` (inbox) or `Attention` with `params: {id}`. Conversation and answer/read/close operations are excluded. The seeded request uses the normal request renderer, not a test-only view: plain single-paragraph text renders as one containing native text root; rich Markdown or JSON reports can render multiple elements. Source-level JSX tests cover the fixture body and request title; native accessibility-tree observation remains a separate XCUITest proof.

Visible inbox IDs come from the committed list's viewability callback; opened request title/question come from the committed Attention renderer. Rich/transformed questions and unsupported Attention branches are not reported. Navigation waits up to five seconds for a matching committed snapshot, never echoes the requested route. `clientControlWire.ts` uses generated JCS/canonical bytes and the existing device-key signer; tests verify the shared generated golden vectors. Enrollment revoke uses the SDK's `clientTestRevoke`. Test pairing retains its own key and generated device/session identity even without `control.messages`; ordinary pairing retention is unchanged.

Observation reporting holds one send slot during both batch preparation and transport. Closing a measured minute can synchronously notify the reporter; that notification cannot prepare a second batch while the first is being formed. A null or empty batch releases the slot without delaying the next measurement.

## Layout
Expand Down
41 changes: 41 additions & 0 deletions apps/ios/clientControlRender.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
import { markdown } from './markdown.ts';
import { report, spaced } from '../../clients/typescript/fractal-views/requestView.ts';

// Execute the shipped JSX definitions; host Text roots model native staticText
// (nested styled Text runs belong to their containing native text element).
const source = path => ts.createSourceFile(path, readFileSync(new URL(path, import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const find = (node, predicate) => {
if (predicate(node)) return node;
return ts.forEachChild(node, child => find(child, predicate));
};
const definitions = (file, names) => names.map(name => {
const node = find(file, node => ts.isFunctionDeclaration(node) && node.name?.text === name);
assert.ok(node, `shipped function ${name}`);
return `export ${node.getText(file).replace(/^export /, '')}`;
}).join('\n');
const React = { Fragment: 'Fragment', createElement: (type, props, ...children) => typeof type === 'function' ? type({ ...props, children }) : { type, children } };
const evaluate = (code, environment) => new Function(...Object.keys(environment), 'exports', ts.transpileModule(code, { compilerOptions: { jsx: ts.JsxEmit.React, module: ts.ModuleKind.CommonJS } }).outputText + '\nreturn exports;')(...Object.values(environment), {});
const flatten = node => Array.isArray(node) ? node.map(flatten).join('') : typeof node === 'string' ? node : node?.children ? flatten(node.children) : '';
const textElements = node => Array.isArray(node) ? node.flatMap(textElements) : node?.type === 'Text' ? [flatten(node)] : node?.children ? textElements(node.children) : [];

test('fixture question and request title render as one exact native text root on shipped paths', () => {
const ui = source('./ui.tsx'), alerts = source('./screens/Alerts.tsx'), now = source('./screens/Now.tsx');
const appearance = { system: true, color: color => color, colors: {}, palette: {} };
const environment = { React, Text: 'Text', View: 'View', markdown, report, spaced, useTextAppearance: () => appearance, theme: {}, styles: { text: {}, bold: {} }, openDocumentLink() {} };
const components = evaluate(definitions(ui, ['T', 'Runs', 'Markdown']), environment);
const { RequestQuestion } = evaluate(definitions(alerts, ['RequestQuestion']), { ...environment, ...components });
const question = 'client-control fixture 0001';
assert.equal(report(question), null);
assert.deepEqual(textElements(RequestQuestion({ text: question })), [question]);
const screen = find(now, node => ts.isFunctionDeclaration(node) && node.name?.text === 'AttentionScreen');
const requestBranch = find(screen, node => ts.isIfStatement(node) && node.expression.getText(now).startsWith('isRequest(item.attention_kind)'));
const title = find(requestBranch, node => ts.isJsxElement(node) && node.getText(now) === '<T bold selectable>{row.title}</T>');
assert.ok(title, 'existing request title element at Now.tsx request branch');
const titleText = 'client-control fixture ask';
const rendered = evaluate(`export const title = () => (${title.getText(now)});`, { ...environment, ...components, row: { title: titleText } }).title();
assert.deepEqual(textElements(rendered), [titleText]);
});
27 changes: 27 additions & 0 deletions apps/ios/clientControlVisible.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { committedVisible, publishVisible, subscribeVisible } from './clientControlVisible.ts';

test('committed state is isolated by navigator route key and removed on cleanup', () => {
const state = { route: 'Attention', screen: 'Attention', visible_item_ids: ['ask/one'], opened_item: { id: 'ask/one', title: 'A request', body: 'Question rendered by the normal request view' } };
assert.equal(committedVisible('not-committed'), undefined);
publishVisible('committed-attention', state);
assert.equal(committedVisible('committed-attention'), state);
assert.equal(committedVisible('another-attention-instance'), undefined);
publishVisible('committed-attention', undefined);
assert.equal(committedVisible('committed-attention'), undefined);
});

test('viewability publishes only renderer-observed IDs, including an empty list', () => {
let notifications = 0;
const stop = subscribeVisible(() => notifications++);
publishVisible('now', { route: 'NowRoot', screen: 'NowRoot', visible_item_ids: ['ask/visible'], opened_item: null });
assert.deepEqual(committedVisible('now').visible_item_ids, ['ask/visible']);
publishVisible('now', { route: 'NowRoot', screen: 'NowRoot', visible_item_ids: [], opened_item: null });
assert.deepEqual(committedVisible('now').visible_item_ids, []);
publishVisible('now', undefined);
assert.equal(notifications, 3);
stop();
publishVisible('now', undefined);
assert.equal(notifications, 3);
});
40 changes: 38 additions & 2 deletions apps/ios/screens/Now.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { Alert, Linking, Pressable, ScrollView, SectionList, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { Alert, Linking, Pressable, ScrollView, SectionList, View, type ViewToken } from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Banners, Empty, StatusLine, useDebugScroll, useMissionsOnFocus, useRefresh } from '../chrome';
import { ContextMenu } from '../menu';
Expand All @@ -16,16 +16,36 @@ import { AlertBody, RequestAnswers, RequestQuestion, StructuredRequestView } fro
import { theme } from '../theme';
import { Button, GroupedHeader, GroupedRow, Markdown, Note, SystemScreen as Screen, T } from '../ui';
import type { RootScreen } from '../navigation';
import { observedQuestion, publishVisible } from '../clientControlVisible';

// Now: what needs the person, as the Fractal terminal UI's Home lists it.
export function NowScreen() {
const { data, caps, hasSynced, truncated, loadErrors, busy, status, sync, actions } = useStore();
const navigation = useNavigation<NativeStackNavigationProp<RootParams>>();
const list = useRef<SectionList<HomeRow>>(null);
const route = useRoute();
const visibleIds = useRef<string[] | undefined>(undefined);
const viewability = useRef(({ viewableItems }: { viewableItems: ViewToken<HomeRow>[] }) => {
visibleIds.current = viewableItems.filter(token => token.isViewable && token.item?.item?.id).map(token => token.item.item.id);
publishVisible(route.key, { route: 'NowRoot', screen: 'NowRoot', visible_item_ids: visibleIds.current, opened_item: null });
}).current;
useDebugScroll(list as never);
const refresh = useRefresh();
const agentOf = (row: HomeRow) => [row.item.requester_id, row.item.source_id].find(id => id?.startsWith('agent/'));
const rows = useMemo(() => homeRows(data.attention, caps?.session_actor), [data.attention, caps?.session_actor]);
useLayoutEffect(() => {
if (rows.length === 0) visibleIds.current = [];
if (visibleIds.current) {
const ids = new Set(rows.map(row => row.item.id));
if (rows.length > 0 && (visibleIds.current.length === 0 || visibleIds.current.some(id => !ids.has(id)))) {
visibleIds.current = undefined;
publishVisible(route.key, undefined);
} else {
publishVisible(route.key, { route: 'NowRoot', screen: 'NowRoot', visible_item_ids: visibleIds.current, opened_item: null });
}
}
return () => publishVisible(route.key, undefined);
}, [rows, route.key]);
const requesterOf = (row: HomeRow) => {
const id = row.item.requester_id ?? row.item.source_id;
const agent = data.agents.find(candidate => candidate.id === id);
Expand Down Expand Up @@ -69,6 +89,7 @@ export function NowScreen() {
ref={list}
contentInsetAdjustmentBehavior="automatic"
sections={sections}
onViewableItemsChanged={viewability}
keyExtractor={row => row.item.id}
stickySectionHeadersEnabled={false}
renderSectionHeader={({ section }) => <GroupedHeader title={section.title} count={section.count} />}
Expand Down Expand Up @@ -105,6 +126,21 @@ export function AttentionScreen({ route, navigation }: RootScreen<'Attention'>)
const kept = useRef(found);
if (found) kept.current = found;
const item = found ?? kept.current;
const question = item?.request ? observedQuestion(item.request.question) : undefined;
// Only the open request branch below has a precisely observable title/question.
// In particular, never substitute the requested ID or stale store data for a render.
useLayoutEffect(() => {
if (!item || item.id !== route.params.id || item.closedElsewhere || item.update
|| !isRequest(item.attention_kind) || !item.actions.includes('work.done') || !item.request || question === undefined) {
publishVisible(route.key, undefined);
} else {
publishVisible(route.key, {
route: 'Attention', screen: 'Attention', visible_item_ids: [item.id],
opened_item: { id: item.id, title: detailRow(item).title, body: question },
});
}
return () => publishVisible(route.key, undefined);
}, [item, question, route.key, route.params.id]);
if (!item) return <Screen><Banners /><Empty text={loadErrors.attention ? 'This alert could not be loaded.' : !hasSynced ? 'Checking for this alert…' : status !== 'online' ? 'This alert is not in the offline snapshot. Reconnect to check it.' : 'This item is no longer open.'} /></Screen>;
const row = detailRow(item);
const agentId = [item.requester_id, item.source_id].find(id => id?.startsWith('agent/'));
Expand Down
Loading