Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
19 changes: 18 additions & 1 deletion apps/mobile/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@
},
"ignorePatterns": ["dist/*", "node_modules/**"],
"rules": {
"no-restricted-globals": [
"error",
{
"name": "Intl",
"message": "Use @/lib/intl-cache so Hermes support stays explicit and tested."
}
],
"no-restricted-imports": [
"error",
{
Expand Down Expand Up @@ -253,14 +260,24 @@
"anti-slop/no-reflect-get": "off",
"anti-slop/no-runtime-typeof": "off",
"anti-slop/no-unknown-returns": "off",
"no-restricted-globals": "off",
"zod-utils/no-inline-zod-schema": "off"
}
},
{
"files": ["src/i18n/catalogs.ts"],
"files": ["src/i18n/catalogs.ts", "src/lib/relative-time-locales.ts"],
"rules": {
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-var-requires": "off",
"unicorn/prefer-module": "off"
}
},
{
"files": ["src/lib/intl-cache.ts"],
"rules": {
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-var-requires": "off",
"no-restricted-globals": "off",
"unicorn/prefer-module": "off"
}
},
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
"@expo-google-fonts/jetbrains-mono": "0.4.1",
"@expo/app-integrity": "57.0.1",
"@expo/react-native-action-sheet": "4.1.1",
"@formatjs/intl-durationformat": "^0.10.18",
"@formatjs/intl-listformat": "^8.3.13",
"@formatjs/intl-locale": "^5.3.10",
"@formatjs/intl-numberformat": "^9.4.0",
"@formatjs/intl-relativetimeformat": "^12.3.13",
"@formatjs/intl-segmenter": "^12.2.13",
"@kilocode/app-shared": "workspace:*",
"@kilocode/cloud-agent-sdk": "workspace:*",
"@kilocode/event-service": "workspace:*",
Expand Down
39 changes: 18 additions & 21 deletions apps/mobile/src/app/(app)/(tabs)/(2_agents)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,47 +41,44 @@ export function buildGitHubInstallOutcomeAlert(
switch (result.kind) {
case 'success': {
return {
title: 'GitHub App installed',
message: 'Your repositories are now connected.',
buttons: [{ text: 'Continue' }],
title: i18n.t('agents.githubInstall.installedTitle'),
message: i18n.t('agents.githubInstall.installedMessage'),
buttons: [{ text: i18n.t('common.continue') }],
};
}
case 'pending': {
return {
title: 'Awaiting admin approval',
message: 'An organization admin must approve the installation request.',
buttons: [{ text: 'Done' }],
title: i18n.t('agents.githubInstall.pendingTitle'),
message: i18n.t('agents.githubInstall.pendingMessage'),
buttons: [{ text: i18n.t('common.done') }],
};
}
case 'error': {
if (result.code === 'install_state_user_mismatch') {
return {
title: 'Account mismatch',
message:
'This connection was started from the Kilo App signed in as a different account. Sign in to the web with that account, or start again from the app.',
buttons: [{ text: 'Back' }],
title: i18n.t('agents.githubInstall.accountMismatchTitle'),
message: i18n.t('agents.githubInstall.accountMismatchMessage'),
buttons: [{ text: i18n.t('common.back') }],
};
}
if (result.code === 'not_installation_admin') {
return {
title: 'Cannot complete installation',
message:
'Only a GitHub admin of that account can connect it. Ask an organization admin to install Kilo.',
buttons: [{ text: 'Back' }],
title: i18n.t('agents.githubInstall.cannotCompleteTitle'),
message: i18n.t('agents.githubInstall.notAdminMessage'),
buttons: [{ text: i18n.t('common.back') }],
};
}
if (result.code === 'installation_already_claimed') {
return {
title: 'Cannot complete installation',
message:
'That GitHub installation is already connected to another Kilo account. Disconnect it there first.',
buttons: [{ text: 'Back' }],
title: i18n.t('agents.githubInstall.cannotCompleteTitle'),
message: i18n.t('agents.githubInstall.alreadyClaimedMessage'),
buttons: [{ text: i18n.t('common.back') }],
};
}
return {
title: 'Installation did not complete',
message: 'The GitHub App installation was not completed. Try again from the app.',
buttons: [{ text: 'Try again', onPress: onRetry }],
title: i18n.t('agents.githubInstall.didNotCompleteTitle'),
message: i18n.t('agents.githubInstall.didNotCompleteMessage'),
buttons: [{ text: i18n.t('common.tryAgain'), onPress: onRetry }],
};
}
default: {
Expand Down
10 changes: 3 additions & 7 deletions apps/mobile/src/app/(app)/kiloclaw/[instance-id]/billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { useKiloClawBillingStatus } from '@/lib/hooks/use-kiloclaw-queries';
import { formatBillingDate, formatRemainingDays } from '@/lib/hooks/use-kiloclaw-billing';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { useTRPC } from '@/lib/trpc';
import { cn } from '@/lib/utils';
import { capitalize, cn } from '@/lib/utils';

function DetailRow({
label,
Expand Down Expand Up @@ -132,8 +132,7 @@ function PlanDetails({
return <FinalCommitTermDetails billing={billing} />;
}
if (billing.subscription) {
const planName =
billing.subscription.plan.charAt(0).toUpperCase() + billing.subscription.plan.slice(1);
const planName = capitalize(billing.subscription.plan);
const cancelling = billing.subscription.cancelAtPeriodEnd;
return (
<View>
Expand Down Expand Up @@ -163,10 +162,7 @@ function PlanDetails({
);
}
if (billing.earlybird) {
const daysText =
billing.earlybird.daysRemaining === 1
? t('kiloclaw.billing.oneDayLeft')
: t('kiloclaw.billing.daysLeft', { count: billing.earlybird.daysRemaining });
const daysText = formatRemainingDays(billing.earlybird.daysRemaining);
return (
<View>
<DetailRow label={t('kiloclaw.billing.plan')} value={t('kiloclaw.billing.earlybird')} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
useKiloClawPairing,
} from '@/lib/hooks/use-kiloclaw-queries';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { capitalize } from '@/lib/utils';

const CHANNEL_LABELS = {
telegram: 'Telegram',
Expand All @@ -38,6 +39,12 @@ const CHANNEL_LABELS = {
github: 'GitHub',
} satisfies Record<string, string>;

function channelLabel(channel: string): string {
return Object.hasOwn(CHANNEL_LABELS, channel)
? CHANNEL_LABELS[channel as keyof typeof CHANNEL_LABELS]
: capitalize(channel);
}

export default function DevicePairingScreen() {
const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>();
const instanceContext = useInstanceContext(instanceId);
Expand Down Expand Up @@ -128,9 +135,7 @@ export default function DevicePairingScreen() {
const hasAnyRequests = channelRequests.length > 0 || deviceRequests.length > 0;

function handleApproveChannel(channel: string, code: string) {
const label = Object.hasOwn(CHANNEL_LABELS, channel)
? CHANNEL_LABELS[channel as keyof typeof CHANNEL_LABELS]
: channel.charAt(0).toUpperCase() + channel.slice(1);
const label = channelLabel(channel);
Alert.alert(
t('kiloclaw.devicePairing.approveTitle'),
t('kiloclaw.devicePairing.approveMessage', { label, code }),
Expand Down Expand Up @@ -212,11 +217,7 @@ export default function DevicePairingScreen() {
<MessageSquare size={18} color={colors.foreground} />
)}
<View className="flex-1 gap-0.5">
<Text className="text-sm font-medium">
{Object.hasOwn(CHANNEL_LABELS, request.channel)
? CHANNEL_LABELS[request.channel as keyof typeof CHANNEL_LABELS]
: request.channel.charAt(0).toUpperCase() + request.channel.slice(1)}
</Text>
<Text className="text-sm font-medium">{channelLabel(request.channel)}</Text>
<View className="flex-row items-center gap-1.5">
<View className="rounded bg-muted px-1.5 py-0.5">
<Text className="text-xs font-mono text-muted-foreground">
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
useLanguagePreference,
} from '@/lib/hooks/use-language-preference';
import { useTrackingPermissionPrompt } from '@/lib/hooks/use-tracking-permission-prompt';
import { prewarmIntl } from '@/lib/intl-cache';
import {
captureLaunchDeepLink,
getPendingDeepLink,
Expand Down Expand Up @@ -369,8 +370,10 @@ function RootLayoutNav() {
}
try {
await i18n.changeLanguage(resolved);
prewarmIntl(resolved);
} catch {
await i18n.changeLanguage('en');
prewarmIntl('en');
}
void renameAndroidNotificationChannels();
if (!cancelled) {
Expand Down
13 changes: 7 additions & 6 deletions apps/mobile/src/components/agents/attachment-chip-description.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { formatFileSize } from '@kilocode/kilo-chat';

import { i18n } from '@/i18n';
import { formatFileSize, formatPercent } from '@/lib/format';

export type ChipStateInput = {
filename: string;
Expand Down Expand Up @@ -51,7 +50,7 @@ export function progressLabel(progress: number | null): string {
if (progress >= 1) {
return i18n.t('agentChat.attachmentPreview.uploaded');
}
return `${Math.round(progress * 100)}%`;
return formatPercent(progress * 100, i18n.language);
}

/**
Expand Down Expand Up @@ -85,11 +84,13 @@ export function describeAttachmentChip(state: ChipStateInput): ChipDescription {

return {
filename,
sizeText: formatFileSize(size),
sizeText: formatFileSize(size, i18n.language),
progressText,
message,
accessibilityLabel:
message !== null ? `${filename}, ${message}` : `${filename}, ${progressText}`,
accessibilityLabel: i18n.t('chat.attachmentPreview.accessibility', {
filename,
status: message ?? progressText,
}),
showRetry: isRetryable,
showRemove: true,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('describeAttachmentChip — happy / in-flight', () => {
it('shows filename and human-readable size for a fully uploaded chip', () => {
const desc = describeAttachmentChip(baseState);
expect(desc.filename).toBe('doc.pdf');
expect(desc.sizeText).toBe('1 KB');
expect(desc.sizeText).toBe('1 kB');
expect(desc.progressText).toBe('Uploaded');
expect(desc.message).toBeNull();
expect(desc.accessibilityLabel).toBe('doc.pdf, Uploaded');
Expand All @@ -52,7 +52,7 @@ describe('describeAttachmentChip — happy / in-flight', () => {
expect(desc.showRetry).toBe(false);
expect(desc.showRemove).toBe(true);
// Size and filename are still surfaced so the user knows what is uploading.
expect(desc.sizeText).toBe('1 KB');
expect(desc.sizeText).toBe('1 kB');
expect(desc.filename).toBe('doc.pdf');
});

Expand All @@ -75,7 +75,7 @@ describe('describeAttachmentChip — happy / in-flight', () => {
progress: 0,
});
expect(desc.filename).toBe('doc.pdf');
expect(desc.sizeText).toBe('1 KB');
expect(desc.sizeText).toBe('1 kB');
expect(desc.progressText).toBe('0%');
expect(desc.message).toBeNull();
expect(desc.accessibilityLabel).toBe('doc.pdf, 0%');
Expand All @@ -95,7 +95,7 @@ describe('describeAttachmentChip — unhappy, retryable (network/timeout/408/429
expect(desc.showRetry).toBe(true);
expect(desc.showRemove).toBe(true);
// Size + filename are still surfaced so the user knows which file failed.
expect(desc.sizeText).toBe('1 KB');
expect(desc.sizeText).toBe('1 kB');
expect(desc.filename).toBe('doc.pdf');
});

Expand Down Expand Up @@ -130,7 +130,7 @@ describe('describeAttachmentChip — unhappy, non-retryable (presign BAD_REQUEST
// Filename + size are still surfaced so the user knows which file is
// permanently rejected.
expect(desc.filename).toBe('doc.pdf');
expect(desc.sizeText).toBe('1 KB');
expect(desc.sizeText).toBe('1 kB');
});

it('keeps progressText empty for terminal failures (no percent leaked into the message)', () => {
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/components/agents/blocking-card-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { type Component, type RefObject } from 'react';

import { i18n } from '@/i18n';
import { formatNumber } from '@/lib/format';
import { readTrpcErrorField } from '@/lib/trpc-error';

import { type BlockingInteraction } from './agent-interaction-policy';
Expand Down Expand Up @@ -216,7 +217,10 @@ function buildAnnouncement(kind: BlockingCardKind, state: BlockingCardUiState):
*/
export function formatBlockingCardTitle(baseTitle: string, count: number): string {
return count > 1
? `${baseTitle} ${i18n.t('agentChat.blockingCard.positionHint', { count })}`
? `${baseTitle} ${i18n.t('agentChat.blockingCard.positionHint', {
count,
displayCount: formatNumber(count, i18n.language),
})}`
: baseTitle;
}

Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/components/agents/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ import {
import { describeClassificationFailure } from '@/lib/agent-attachments/validate';
import { useAndroidPendingPickerRecovery } from '@/lib/agent-attachments/use-android-pending-picker-recovery';
import {
CLIPBOARD_PASTE_EMPTY_MESSAGE,
clipboardPasteEmptyMessage,
useClipboardPaste,
} from '@/lib/agent-attachments/use-clipboard-paste';
import { type ModelOption } from '@/lib/hooks/use-available-models';
Expand Down Expand Up @@ -543,7 +543,7 @@ export function ChatComposer({
},
onFailure: reason => {
toast.error(
reason === 'empty' ? CLIPBOARD_PASTE_EMPTY_MESSAGE : describeClassificationFailure(reason)
reason === 'empty' ? clipboardPasteEmptyMessage() : describeClassificationFailure(reason)
);
},
maxBytes: AGENT_ATTACHMENT_MAX_BYTES,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ describe('chat link actions', () => {
await performChatLinkAction('open', 'https://kilo.ai/docs?source=chat#links');

expect(openExternalUrl).toHaveBeenCalledWith('https://kilo.ai/docs?source=chat#links', {
label: 'link',
retryOnError: true,
});
});
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/agents/chat-link-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ function showRetryableError(message: string, retry: () => Promise<void>) {

export async function performChatLinkAction(action: ChatLinkAction, href: string): Promise<void> {
if (action === 'open') {
await openExternalUrl(href, { label: 'link', retryOnError: true });
await openExternalUrl(href, { retryOnError: true });
return;
}

Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/agents/chat-markdown-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function ChatMarkdownText(props: Readonly<ChatMarkdownTextProps>) {
return;
}
if (action === 'open') {
void openExternalUrl(href, { label: 'link' });
void openExternalUrl(href);
return;
}
if (action === 'share') {
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/components/agents/child-session-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ export function ChildSessionSection({
return (
<Animated.View
layout={LinearTransition.duration(200)}
className="overflow-hidden rounded-r-lg"
className="overflow-hidden rounded-e-lg"
// eslint-disable-next-line react-native/no-inline-styles -- dynamic border color
style={{ borderLeftWidth: 2, borderLeftColor: borderColor }}
style={{ borderStartWidth: 2, borderStartColor: borderColor }}
>
<Pressable
className="flex-row items-center gap-2 px-3 py-2 active:bg-secondary"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ describe('formatCompactTokens', () => {
it('formats below one thousand, thousands, and millions', () => {
expect(formatCompactTokens(0)).toBe('0');
expect(formatCompactTokens(999)).toBe('999');
expect(formatCompactTokens(1000)).toBe('1.0K');
expect(formatCompactTokens(1000)).toBe('1K');
expect(formatCompactTokens(32_418)).toBe('32.4K');
expect(formatCompactTokens(1500)).toBe('1.5K');
expect(formatCompactTokens(995_000)).toBe('995.0K');
expect(formatCompactTokens(995_000)).toBe('995K');
expect(formatCompactTokens(1_200_000)).toBe('1.2M');
});
});
Expand Down Expand Up @@ -316,6 +316,7 @@ describe('getMetricsAccessibilityLabel', () => {
expect(label).toContain('84,000');
expect(label).toContain('200,000');
expect(label).toContain('42%');
expect(label).not.toContain('%%');
expect(label).toContain('8 cents');
expect(label).not.toContain('$');
expect(label.toLowerCase()).toContain('context details');
Expand Down
Loading