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
@@ -1,3 +1,4 @@
import { CLOUD_AGENT_PROMPT_MAX_LENGTH } from '@kilocode/cloud-agent-sdk/limits';
import { describe, expect, it, vi } from 'vitest';

import {
Expand Down Expand Up @@ -73,8 +74,8 @@ describe('constants', () => {
expect(PROMPT_MIN_LENGTH).toBe(3);
});

it('promptMaxLength is 4000', () => {
expect(PROMPT_MAX_LENGTH).toBe(4000);
it('promptMaxLength is the shared cloud agent prompt cap', () => {
expect(PROMPT_MAX_LENGTH).toBe(CLOUD_AGENT_PROMPT_MAX_LENGTH);
});

it('mode is "code"', () => {
Expand Down
3 changes: 2 additions & 1 deletion apps/extension/entrypoints/sidepanel/agents-new-session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { fetchModelPreferences } from '@/src/shared/model-preferences-client';
import { isGatewayModelId } from '@/src/shared/model-picker-rows';
import { getModelPreferencesQueryKey } from '@/src/shared/side-panel-query-options';
import { thinkingEffortLabel } from '@/src/shared/kilo-api-client';
import { CLOUD_AGENT_PROMPT_MAX_LENGTH } from '@kilocode/cloud-agent-sdk/limits';
import type { KiloGatewayModelOption } from '@/src/shared/kilo-api-client';
import { useExtensionAgents } from './agents-provider';
import { activeSessionsQueryKey, sessionHistoryQueryKey } from './agents-session-list';
Expand All @@ -43,7 +44,7 @@ import { useGatewayModels } from './use-gateway-models';
// ---------------------------------------------------------------------------

const PROMPT_MIN_LENGTH = 3;
const PROMPT_MAX_LENGTH = 4000;
const PROMPT_MAX_LENGTH = CLOUD_AGENT_PROMPT_MAX_LENGTH;
const MODE = 'code' as const;

/**
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ git diff --check
- iOS: never control text with `value` plus state. Store text in a ref via `onChangeText`, use state only for derived UI, read the ref on submit.
- Use `defaultValue` only for initial content.
- Single-line inputs: use `leading-[normal]`. A `lineHeight` above the font's natural one (which `text-sm`/`text-base` set on their own) makes iOS draw the placeholder lower than the typed text and clip it. Multi-line inputs keep an explicit `leading-*`.
- Single-line inputs: set the height with `min-h-*`, not `py-*`. iOS insets the already-centered text rect by the padding, so vertical padding draws the text and the placeholder low.
- Put input screens in a `ScrollView` with `automaticallyAdjustKeyboardInsets`.

## UI and UX Rules
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ArrowUp, Paperclip, Square } from '@/components/ui/icons';
import { CLOUD_AGENT_PROMPT_MAX_LENGTH } from '@kilocode/cloud-agent-sdk/limits';
import { type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import {
Expand Down Expand Up @@ -119,7 +120,7 @@ export function ChatComposerInputRow({
placeholder={placeholder}
placeholderTextColor={colors.mutedForeground}
multiline
maxLength={4000}
maxLength={CLOUD_AGENT_PROMPT_MAX_LENGTH}
onChangeText={onChangeText}
onFocus={onInputFocus}
onBlur={onInputBlur}
Expand Down
7 changes: 4 additions & 3 deletions apps/mobile/src/components/agents/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import * as Haptics from 'expo-haptics';
import { useActionSheet } from '@expo/react-native-action-sheet';
import { type SlashCommandInfo } from '@kilocode/cloud-agent-sdk';
import { CLOUD_AGENT_PROMPT_MAX_LENGTH } from '@kilocode/cloud-agent-sdk/limits';
import { type RemoteCommandState } from '@kilocode/cloud-agent-sdk/remote-command-catalog';
import {
type Ref,
Expand Down Expand Up @@ -474,7 +475,7 @@ export function ChatComposer({
useSharePrefill({
shareId,
inputRef,
maxLength: 4000,
maxLength: CLOUD_AGENT_PROMPT_MAX_LENGTH,
onChangeText: handleChangeText,
addCandidates,
onDelivered: () => {
Expand All @@ -500,7 +501,7 @@ export function ChatComposer({
applyVoiceDraftToInput({
input: inputRef.current,
draft,
maxLength: 4000,
maxLength: CLOUD_AGENT_PROMPT_MAX_LENGTH,
onChangeText: handleChangeText,
});
},
Expand Down Expand Up @@ -537,7 +538,7 @@ export function ChatComposer({
input: inputRef.current,
draft: textRef.current,
selection: selectionRef.current,
maxLength: 4000,
maxLength: CLOUD_AGENT_PROMPT_MAX_LENGTH,
onChangeText: handleChangeText,
});
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -548,4 +548,29 @@ describe('NewSessionConfigureForm', () => {
expect(findElementByType(element, 'SegmentedControl')).toBeNull();
expect(findTextContent(element, t => t === 'Changes')).toBe(false);
});

// ── Case 12: kilo remote hint ──
it('names both `kilo remote` and `/remote` for cloud and remote targets', async () => {
const { NewSessionConfigureForm } = await import('./new-session-configure-form');

// eslint-disable-next-line new-cap -- plain function call, matching repo test convention
const cloud = NewSessionConfigureForm({
...defaultProps(),
runOnInstance: null,
showRunOnSelector: true,
}) as Node;
expect(findTextContent(cloud, t => t.includes('kilo remote') && t.includes('/remote'))).toBe(
true
);

// eslint-disable-next-line new-cap -- plain function call, matching repo test convention
const remote = NewSessionConfigureForm({
...defaultProps(),
runOnInstance: INSTANCE,
showRunOnSelector: false,
}) as Node;
expect(findTextContent(remote, t => t.includes('kilo remote') && t.includes('/remote'))).toBe(
true
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ export function NewSessionConfigureForm({

{runTargetBlock}

<Text className="mt-2 text-xs text-muted-foreground">
{t('agentChat.newSession.remoteHint')}
</Text>

{showInstanceDisconnectedNote ? (
<Text className="mt-2 text-sm text-muted-foreground">
{remoteSpawnInstanceDisconnectedNote()}
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/components/agents/new-session-prompt.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CLOUD_AGENT_PROMPT_MAX_LENGTH } from '@kilocode/cloud-agent-sdk/limits';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
type LayoutChangeEvent,
Expand Down Expand Up @@ -45,7 +46,7 @@ const PROMPT_INPUT_LINE_HEIGHT = 24;
const PROMPT_INPUT_VERTICAL_PADDING = 16;
const PROMPT_INPUT_HORIZONTAL_PADDING = Platform.OS === 'android' ? 48 : 16;
const PROMPT_INPUT_ANDROID_HORIZONTAL_INSET = 24;
const PROMPT_INPUT_MAX_CHARS = 4000;
const PROMPT_INPUT_MAX_CHARS = CLOUD_AGENT_PROMPT_MAX_LENGTH;
const PROMPT_INPUT_MIN_HEIGHT =
PROMPT_INPUT_LINE_HEIGHT * PROMPT_INPUT_DEFAULT_LINES + PROMPT_INPUT_VERTICAL_PADDING;
const PROMPT_INPUT_MAX_HEIGHT =
Expand Down
212 changes: 184 additions & 28 deletions apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
/* eslint-disable max-lines -- spawn-input, navigation, and admission suites share the hook harness. */
import * as React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type ModelSelection } from '@kilocode/cloud-agent-sdk';
import { type KiloSessionId, type ModelSelection } from '@kilocode/cloud-agent-sdk';

import { type InstancePickerInstance } from '@/lib/picker-bridge';
import {
__resetSharePayloadStoreForTests,
peekSharePayload,
type SharePayload,
} from '@/lib/share-payload';
import { buildCreateRemoteSessionInput } from '@/lib/hooks/remote-instance-spawn-classifier';
import {
buildCreateRemoteSessionInput,
type CreateSessionOutcome,
} from '@/lib/hooks/remote-instance-spawn-classifier';
import { remoteSpawnFilesNotSupportedToast } from '@/lib/remote-spawn-admission';
import { remoteSpawnRetryableToast } from '@/lib/remote-submit-outcome';

import { useRemoteSpawnDispatch } from './use-remote-spawn-dispatch';

const spawnMock = vi.hoisted(() =>
vi.fn(async () => {
vi.fn(async (): Promise<CreateSessionOutcome> => {
await Promise.resolve();
return {
status: 'ready' as const,
sessionID: 'ses_12345678901234567890123456',
sessionID: 'ses_12345678901234567890123456' as KiloSessionId,
};
})
);
Expand Down Expand Up @@ -125,7 +129,11 @@ function runHook(args: {
selection?: ModelSelection;
getSubmitPayload?: () => SharePayload | null;
onSpawnAdmitted?: () => void;
onSpawnFailed?: () => void;
runOnInstance?: InstancePickerInstance | null;
setRunOnInstance?: (next: InstancePickerInstance | null) => void;
refetchInstances?: () => Promise<{ data: { instances: InstancePickerInstance[] } | undefined }>;
instanceList?: InstancePickerInstance[];
}) {
const reactInternals = React as typeof React & ReactInternals;
const hookState: unknown[] = [];
Expand Down Expand Up @@ -170,30 +178,50 @@ function runHook(args: {
},
};

const previousDispatcher =
reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H;
hookIndex = 0;
refIndex = 0;
reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = dispatcher;
try {
const mountDispatch = useRemoteSpawnDispatch;
return mountDispatch({
organizationId: args.organizationId,
mode: args.mode,
selection: args.selection,
runOnInstance: args.runOnInstance === undefined ? INSTANCE : args.runOnInstance,
// eslint-disable-next-line no-empty-function -- no-op setter for harness
setRunOnInstance: (_next: InstancePickerInstance | null) => {},
// eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
refetchInstances: () => Promise.resolve({ data: { instances: [INSTANCE] } }),
instanceList: [INSTANCE],
getSubmitPayload: args.getSubmitPayload,
onSpawnAdmitted: args.onSpawnAdmitted,
});
} finally {
reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H =
previousDispatcher;
}
// `runOnInstance` is parent state in the real route: `setRunOnInstance`
// re-renders the hook with the new value so the `runOnInstanceRef` effect
// sees it. Mirror that here; otherwise the async tail's remap would leave
// the ref on the stale press-time id and the reset guard (which reads the
// ref) could never be exercised by a remap test.
let currentRunOnInstance: InstancePickerInstance | null =
args.runOnInstance === undefined ? INSTANCE : args.runOnInstance;

const render = () => {
hookIndex = 0;
refIndex = 0;
const previousDispatcher =
reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H;
reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = dispatcher;
try {
const mountDispatch = useRemoteSpawnDispatch;
return mountDispatch({
organizationId: args.organizationId,
mode: args.mode,
selection: args.selection,
runOnInstance: currentRunOnInstance,
setRunOnInstance: next => {
args.setRunOnInstance?.(next);
if (next !== currentRunOnInstance) {
currentRunOnInstance = next;
render();
}
},
refetchInstances:
args.refetchInstances ??
// eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
(() => Promise.resolve({ data: { instances: [INSTANCE] } })),
instanceList: args.instanceList ?? [INSTANCE],
getSubmitPayload: args.getSubmitPayload,
onSpawnAdmitted: args.onSpawnAdmitted,
onSpawnFailed: args.onSpawnFailed,
});
} finally {
reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H =
previousDispatcher;
}
};

return render();
}

describe('useRemoteSpawnDispatch spawn input chain', () => {
Expand Down Expand Up @@ -428,3 +456,131 @@ describe('useRemoteSpawnDispatch spawn input chain', () => {
expect(spawnMock).not.toHaveBeenCalled();
});
});

describe('useRemoteSpawnDispatch live-instance remap', () => {
const LIVE_INSTANCE: InstancePickerInstance = {
connectionId: 'conn-live',
name: 'laptop',
projectName: 'kilo',
};

beforeEach(() => {
spawnMock.mockClear();
useRemoteInstanceSpawnMock.mockClear();
routerReplace.mockClear();
toastErrorMock.mockClear();
__resetSharePayloadStoreForTests();
});

it('spawns with the same connectionId when the refetched list still has it', async () => {
const { onStart } = runHook({
organizationId: 'org-xyz',
// eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
refetchInstances: () => Promise.resolve({ data: { instances: [INSTANCE] } }),
});

expect(await captureSpawnCall(onStart)).toEqual([
'conn-abc',
{ orgId: 'org-xyz' },
{ operationKey: expect.any(String) },
]);
});

it('remaps to the live connectionId when the id changed but name + project match', async () => {
const setRunOnInstanceMock = vi.fn();
const { onStart } = runHook({
organizationId: 'org-xyz',
setRunOnInstance: next => {
setRunOnInstanceMock(next);
},
// eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
refetchInstances: () => Promise.resolve({ data: { instances: [LIVE_INSTANCE] } }),
});

expect(await captureSpawnCall(onStart)).toEqual([
'conn-live',
{ orgId: 'org-xyz' },
{ operationKey: expect.any(String) },
]);
expect(setRunOnInstanceMock).toHaveBeenCalledWith(LIVE_INSTANCE);
});

it('falls back to the last-known instanceList when the refetch throws', async () => {
const { onStart } = runHook({
organizationId: 'org-xyz',
// eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
refetchInstances: () => Promise.reject(new Error('network down')),
instanceList: [INSTANCE],
});

expect(await captureSpawnCall(onStart)).toEqual([
'conn-abc',
{ orgId: 'org-xyz' },
{ operationKey: expect.any(String) },
]);
});

it('keeps the live selection when a remap is followed by a failing post-spawn refetch', async () => {
const setRunOnInstanceMock = vi.fn();
// First (pre-spawn) refetch resolves the live row so the id remaps; the
// second (post-spawn) refetch fails.
const refetchInstancesMock = vi
.fn()
.mockResolvedValueOnce({ data: { instances: [LIVE_INSTANCE] } })
.mockRejectedValueOnce(new Error('network down'));
spawnMock.mockResolvedValueOnce({
status: 'retryable',
reason: 'transport failure',
cause: new Error('socket gone'),
});

const { onStart } = runHook({
organizationId: 'org-xyz',
setRunOnInstance: next => {
setRunOnInstanceMock(next);
},
refetchInstances: refetchInstancesMock,
instanceList: [INSTANCE],
});

onStart();
await vi.waitFor(() => {
expect(refetchInstancesMock).toHaveBeenCalledTimes(2);
});
// Flush the rejected-refetch continuation (outcome classification and the
// reset guard) before asserting the selection did not move to null.
await new Promise<void>(resolve => {
setTimeout(resolve, 0);
});

// The remap applied the live row...
expect(setRunOnInstanceMock).toHaveBeenCalledWith(LIVE_INSTANCE);
// ...and the failing refetch must not reset the selection to Cloud Agent.
expect(setRunOnInstanceMock).toHaveBeenCalledTimes(1);
expect(setRunOnInstanceMock).not.toHaveBeenCalledWith(null);
});

it('toasts the retryable copy and calls onSpawnFailed when no live instance resolves', async () => {
const onSpawnFailedMock = vi.fn();
const setRunOnInstanceMock = vi.fn();
const { onStart } = runHook({
organizationId: 'org-xyz',
setRunOnInstance: next => {
setRunOnInstanceMock(next);
},
// eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
refetchInstances: () => Promise.resolve({ data: { instances: [] } }),
onSpawnFailed: () => {
onSpawnFailedMock();
},
});

onStart();
await vi.waitFor(() => {
expect(onSpawnFailedMock).toHaveBeenCalledTimes(1);
});
expect(toastErrorMock).toHaveBeenCalledWith(remoteSpawnRetryableToast());
expect(spawnMock).not.toHaveBeenCalled();
expect(setRunOnInstanceMock).not.toHaveBeenCalled();
});
});
Loading
Loading