Skip to content

Commit 6c7374c

Browse files
authored
fix(mobile): release QA fixes (#4410)
* fix(mobile): keep Copy Link sign-in label on one line on Android * fix(mobile): reset org context and last-active instance on sign-out The organization provider mounts above the auth gate and never remounts, so the previous user's in-memory org id kept being sent after a different account signed in, causing a 401/sign-out loop. Reset it when the token clears. Also clear the last-active-chat-instance key and its module cache, which leaked across accounts the same way. * fix(mobile): drop default cloud-agent platform filter on agents list * fix(mobile): stop native text selection competing with long-press copy Markdown message text defaulted to selectable, so a long press on iOS spawned the native copy callout on top of the copy action sheet. Message text is now non-selectable (matching kilo-chat); tool-card outputs stay selectable since the sheet does not collect them. * fix(mobile): reserve subagent running-tool line to avoid layout shift * fix(cloud-agent-sdk): subscribe the upgrade watcher so read-only sessions go live The watcher from #4390 retained the user-web socket but never subscribed to the session, and the server only pushes sessions.heartbeat and subscribe-triggered sessions.list re-sends to subscribed sockets — so the upgrade trigger never arrived and a session opened inside the heartbeat registration window stayed read-only forever. * fix(cloud-agent-sdk): resume cloud-agent reconnects from a replay cursor Reconnects requested replay=false and relied on a snapshot refetch for catch-up, so events streamed while the socket was dead (including the terminal session.status) were lost if the snapshot had not caught up — truncated messages and a stuck busy indicator after foregrounding. Track the last wire eventId and reconnect with fromId so the DO replays exactly the missed events; the snapshot refetch remains only as the no-cursor fallback, since replaying a stale snapshot over cursor-replayed events would regress content. * fix(cloud-agent-sdk): heal CLI session reconnect gaps CLI sessions have no server-side event log, so a reconnect only streams events from now and the snapshot store lags the live stream — parts finalized while the socket was dead were in neither, and a terminal idle status fired during the gap left the busy indicator stuck. Re-fetch the snapshot once more shortly after reconnect, and forward the per-session idle/busy status that heartbeats already carry so activity self-corrects. * fix(mobile): address review feedback and start-session silent no-op - Toast the specific missing field when Start Session is tapped instead of a silently disabled button - Reset the heartbeat status dedup on CLI disconnect so an unchanged post-reconnect status still clears the disconnected UI state - Gate the org-context storage load on the auth token so an in-flight read cannot reinstate the previous user's org id after sign-out - Serialize last-active-instance persistence writes so a sign-out clear cannot be overtaken by an in-flight set
1 parent 69d2ff0 commit 6c7374c

16 files changed

Lines changed: 225 additions & 47 deletions

‎apps/mobile/src/app/(app)/agent-chat/new.tsx‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ export default function NewSessionScreen() {
8080

8181
// Prompt ref (uncontrolled TextInput on iOS)
8282
const promptRef = useRef('');
83-
const [hasPrompt, setHasPrompt] = useState(false);
8483
const [promptInputWidth, setPromptInputWidth] = useState(0);
8584
const promptMeasure = useTextHeight({
8685
minHeight: PROMPT_INPUT_MIN_HEIGHT,
@@ -163,7 +162,16 @@ export default function NewSessionScreen() {
163162
const handleCreate = useCallback(async () => {
164163
const prompt = promptRef.current.trim();
165164
// The backend requires a non-empty prompt even when attachments are present.
166-
if (!prompt || !selectedRepo || !model) {
165+
if (!prompt) {
166+
toast.error('Enter a prompt first.');
167+
return;
168+
}
169+
if (!selectedRepo) {
170+
toast.error('Select a repository first.');
171+
return;
172+
}
173+
if (!model) {
174+
toast.error('Select a model first.');
167175
return;
168176
}
169177
if (attachments.isUploading) {
@@ -245,8 +253,6 @@ export default function NewSessionScreen() {
245253
attachments,
246254
]);
247255

248-
const canStart = hasPrompt && selectedRepo.length > 0 && model.length > 0 && !isCreating;
249-
250256
const { addCandidates } = attachments;
251257
const handleAddAttachment = useCallback(async () => {
252258
addCandidates(await pickAgentAttachments());
@@ -303,7 +309,6 @@ export default function NewSessionScreen() {
303309
onChangeText={text => {
304310
promptRef.current = text;
305311
promptMeasure.setText(text);
306-
setHasPrompt(text.trim().length > 0);
307312
}}
308313
onLayout={handlePromptInputLayout}
309314
scrollEnabled={promptMeasure.height >= PROMPT_INPUT_MAX_HEIGHT}
@@ -374,7 +379,7 @@ export default function NewSessionScreen() {
374379
<Button
375380
size="lg"
376381
className="mt-6"
377-
disabled={!canStart}
382+
disabled={isCreating}
378383
onPress={() => {
379384
void handleCreate();
380385
}}

‎apps/mobile/src/components/agents/child-session-section.tsx‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,16 @@ export function ChildSessionSection({
152152
<Text className="text-sm text-foreground" numberOfLines={1}>
153153
{subtitle}
154154
</Text>
155-
{currentTool ? (
156-
<Text className="text-xs text-muted-foreground" numberOfLines={1}>
157-
<Text className="text-xs text-agent-sky">{currentTool.tool}</Text>
158-
{currentTool.context ? ` ${currentTool.context}` : ''}
155+
{isRunning ? (
156+
<Text className="text-xs leading-4 text-muted-foreground" numberOfLines={1}>
157+
{currentTool ? (
158+
<>
159+
<Text className="text-xs leading-4 text-agent-sky">{currentTool.tool}</Text>
160+
{currentTool.context ? ` ${currentTool.context}` : ''}
161+
</>
162+
) : (
163+
' '
164+
)}
159165
</Text>
160166
) : null}
161167
</View>

‎apps/mobile/src/components/agents/message-bubble.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ export function MessageBubble({
5858
accessibilityHint="Long press to copy message text"
5959
>
6060
<Bubble side="user">
61-
{textContent ? <MarkdownText value={textContent} variant="user" /> : null}
61+
{textContent ? (
62+
<MarkdownText value={textContent} variant="user" selectable={false} />
63+
) : null}
6264
{fileParts.map(part => (
6365
<FilePartRenderer key={part.id} part={part} />
6466
))}

‎apps/mobile/src/components/agents/text-part-renderer.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,7 @@ export function TextPartRenderer({ text }: Readonly<TextPartRendererProps>) {
99
return null;
1010
}
1111

12-
return <MarkdownText value={text} variant="assistant" />;
12+
// selectable={false}: the message Pressable's long-press copy sheet and iOS
13+
// native text selection would both trigger on the same gesture otherwise.
14+
return <MarkdownText value={text} variant="assistant" selectable={false} />;
1315
}

‎apps/mobile/src/components/login-screen.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export function LoginScreen() {
130130
}}
131131
accessibilityLabel="Copy sign-in link"
132132
>
133-
<Text>Copy Link</Text>
133+
<Text numberOfLines={1}>Copy Link</Text>
134134
</Button>
135135
</View>
136136
<Button variant="ghost" onPress={cancel} accessibilityLabel="Cancel sign in">

‎apps/mobile/src/lib/auth/auth-context.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { queryClient } from '@/lib/query-client';
1414
import { setTrpcUnauthorizedHandler } from '@/lib/auth/trpc-unauthorized';
1515
import { clearAgentModelPreference } from '@/lib/hooks/use-persisted-agent-model';
1616
import { clearReasoningPreference } from '@/lib/hooks/use-reasoning-preference';
17+
import { clearLastActiveInstance } from '@/lib/last-active-instance';
1718
import { resetPurchaseErrorToastDedup } from '@/lib/kilo-pass/use-store-kilo-pass-purchase';
1819
import {
1920
AUTH_TOKEN_KEY,
@@ -63,6 +64,7 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) {
6364
await SecureStore.deleteItemAsync(ORGANIZATION_STORAGE_KEY);
6465
await SecureStore.deleteItemAsync(SESSION_FILTERS_KEY);
6566
await SecureStore.deleteItemAsync(NOTIFICATION_PROMPT_SEEN_KEY);
67+
await clearLastActiveInstance();
6668
clearAgentModelPreference();
6769
clearReasoningPreference();
6870
queryClient.clear();

‎apps/mobile/src/lib/hooks/use-persisted-agent-session-filters.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,10 @@ type AgentSessionFilters = {
1010

1111
type FiltersUpdater = AgentSessionFilters | ((prev: AgentSessionFilters) => AgentSessionFilters);
1212
type StringArrayUpdater = string[] | ((prev: string[]) => string[]);
13-
const DEFAULT_PLATFORM_FILTER = ['cloud-agent'];
1413

1514
function createDefaultFilters(): AgentSessionFilters {
1615
return {
17-
platformFilter: [...DEFAULT_PLATFORM_FILTER],
16+
platformFilter: [],
1817
projectFilter: [],
1918
};
2019
}

‎apps/mobile/src/lib/last-active-instance.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ import { LAST_ACTIVE_INSTANCE_KEY } from '@/lib/storage-keys';
44

55
let cached: string | null = null;
66

7+
// Persistence writes are serialized so a sign-out clear can never be
8+
// overtaken by an in-flight set, which would leak the previous account's
9+
// sandbox id into the next cold start.
10+
let writeQueue: Promise<void> | null = null;
11+
12+
async function enqueueWrite(op: () => Promise<void>): Promise<void> {
13+
const previous = writeQueue;
14+
const next = (async () => {
15+
if (previous) {
16+
try {
17+
await previous;
18+
} catch {
19+
// An earlier failed write must not block the queue.
20+
}
21+
}
22+
await op();
23+
})();
24+
writeQueue = next;
25+
await next;
26+
}
27+
728
export async function loadLastActiveInstance(): Promise<void> {
829
const stored = await SecureStore.getItemAsync(LAST_ACTIVE_INSTANCE_KEY);
930
cached ??= stored;
@@ -15,5 +36,14 @@ export function getLastActiveInstance(): string | null {
1536

1637
export async function setLastActiveInstance(sandboxId: string): Promise<void> {
1738
cached = sandboxId;
18-
await SecureStore.setItemAsync(LAST_ACTIVE_INSTANCE_KEY, sandboxId);
39+
await enqueueWrite(async () => {
40+
await SecureStore.setItemAsync(LAST_ACTIVE_INSTANCE_KEY, sandboxId);
41+
});
42+
}
43+
44+
export async function clearLastActiveInstance(): Promise<void> {
45+
cached = null;
46+
await enqueueWrite(async () => {
47+
await SecureStore.deleteItemAsync(LAST_ACTIVE_INSTANCE_KEY);
48+
});
1949
}

‎apps/mobile/src/lib/organization-context.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
useState,
1010
} from 'react';
1111

12+
import { useAuth } from '@/lib/auth/auth-context';
1213
import { ORGANIZATION_STORAGE_KEY } from '@/lib/storage-keys';
1314

1415
type OrganizationContextValue = {
@@ -21,10 +22,21 @@ type OrganizationContextValue = {
2122
const OrganizationContext = createContext<OrganizationContextValue | undefined>(undefined);
2223

2324
export function OrganizationProvider({ children }: { readonly children: ReactNode }) {
25+
const { token } = useAuth();
2426
const [organizationId, setOrgState] = useState<string | null>(null);
2527
const [isLoaded, setIsLoaded] = useState(false);
2628

29+
// The provider mounts above the auth gate and never remounts, so the
30+
// in-memory selection must be reset when the session ends — otherwise the
31+
// previous user's org id keeps being sent after a different account signs in.
32+
// Loading from storage is gated on the same token so a sign-out cancels any
33+
// in-flight read that would otherwise reinstate the previous user's org id.
2734
useEffect(() => {
35+
if (!token) {
36+
setOrgState(null);
37+
setIsLoaded(true);
38+
return undefined;
39+
}
2840
let cancelled = false;
2941
const load = async () => {
3042
try {
@@ -42,7 +54,7 @@ export function OrganizationProvider({ children }: { readonly children: ReactNod
4254
return () => {
4355
cancelled = true;
4456
};
45-
}, []);
57+
}, [token]);
4658

4759
const setOrganizationId = useCallback((id: string | null) => {
4860
setOrgState(id);

‎apps/web/src/lib/cloud-agent-sdk/cli-live-transport.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,32 @@ describe('CliLiveTransport unified user web connection', () => {
142142
transport.destroy();
143143
});
144144

145+
it('re-forwards an unchanged heartbeat status after a disconnect', () => {
146+
const { userWebConnection, transport, serviceEvents } = createTransportWithSinks();
147+
transport.connect();
148+
149+
const heartbeat = (sessions: Array<{ id: string; status: string; title: string }>) => {
150+
userWebConnection.emitSystem({
151+
event: 'sessions.heartbeat',
152+
data: { connectionId: 'owner', sessions },
153+
});
154+
};
155+
156+
heartbeat([{ id: KILO_SESSION_ID, status: 'idle', title: 'Tracked' }]);
157+
heartbeat([]);
158+
heartbeat([{ id: KILO_SESSION_ID, status: 'idle', title: 'Tracked' }]);
159+
160+
// idle → stopped(disconnected) → idle again: the post-reconnect status must
161+
// be forwarded even though it matches the pre-disconnect one, because only
162+
// a session.status event clears the disconnected UI state.
163+
expect(serviceEvents).toEqual([
164+
{ type: 'session.status', sessionId: KILO_SESSION_ID, status: { type: 'idle' } },
165+
{ type: 'stopped', reason: 'disconnected' },
166+
{ type: 'session.status', sessionId: KILO_SESSION_ID, status: { type: 'idle' } },
167+
]);
168+
transport.destroy();
169+
});
170+
145171
it('buffers chat during initial snapshot replay but does not delay service events', async () => {
146172
let resolveSnapshot: ((snapshot: SessionSnapshot) => void) | undefined;
147173
const fetchSnapshot = jest.fn(

0 commit comments

Comments
 (0)