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
12 changes: 3 additions & 9 deletions apps/mobile/src/components/agents/save-profile-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,9 @@ import { SheetHeader } from '@/components/sheet-header';
import { FormField } from '@/components/ui/form-field';
import { Lock } from '@/components/ui/icons';
import { Text } from '@/components/ui/text';
import { validateProfileName } from '@/lib/agent-profile-forms';
import { validateProfileDescription, validateProfileName } from '@/lib/agent-profile-forms';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';

/** Server bound: `ProfileDescriptionSchema`'s `z.string().max(500)`. */
const PROFILE_DESCRIPTION_MAX_LENGTH = 500;

/** One manual environment variable as the sheet saves it. */
type SaveProfileVar = Readonly<{ key: string; value: string; isSecret: boolean }>;

Expand Down Expand Up @@ -71,7 +68,7 @@ export function SaveProfileSheet({
);
return;
}
if (description.length > PROFILE_DESCRIPTION_MAX_LENGTH) {
if (validateProfileDescription(description) !== null) {
setDescriptionError(t('agentChat.newSession.descriptionTooLong'));
return;
}
Expand Down Expand Up @@ -139,10 +136,7 @@ export function SaveProfileSheet({
autoCorrect={false}
onChangeText={value => {
descriptionRef.current = value;
if (
descriptionError !== null &&
value.trim().length <= PROFILE_DESCRIPTION_MAX_LENGTH
) {
if (descriptionError !== null && validateProfileDescription(value) === null) {
setDescriptionError(null);
}
}}
Expand Down
21 changes: 17 additions & 4 deletions apps/mobile/src/components/profiles/new-profile-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { Button } from '@/components/ui/button';
import { FormField } from '@/components/ui/form-field';
import { SegmentedControl } from '@/components/ui/segmented-control';
import { Text } from '@/components/ui/text';
import { validateProfileName } from '@/lib/agent-profile-forms';
import { validateProfileDescription, validateProfileName } from '@/lib/agent-profile-forms';
import { useAgentProfileMutations } from '@/lib/hooks/use-agent-profiles';
import { useOrganization } from '@/lib/organization-context';
import { getProfileOverviewPath } from '@/lib/profile-agent-navigation';
Expand Down Expand Up @@ -57,16 +57,25 @@ export function NewProfileScreen() {
const descriptionRef = useRef('');
const [owner, setOwner] = useState<ProfileOwnerChoice>('personal');
const [nameError, setNameError] = useState<string | null>(null);
const [descriptionError, setDescriptionError] = useState<string | null>(null);

const submit = async () => {
const name = nameRef.current.trim();
if (validateProfileName(name) !== null) {
setNameError(t('profiles.nameRequired'));
const nameIssue = validateProfileName(name);
if (nameIssue !== null) {
setNameError(
nameIssue === 'empty' ? t('profiles.nameRequired') : t('agentChat.newSession.nameTooLong')
);
return;
}
setNameError(null);

const description = descriptionRef.current.trim();
if (validateProfileDescription(description) !== null) {
setDescriptionError(t('agentChat.newSession.descriptionTooLong'));
return;
}
setNameError(null);
setDescriptionError(null);
const ownerOrganizationId = createOrganizationId(organizationId, owner);
try {
const created = await create.mutateAsync({
Expand Down Expand Up @@ -109,11 +118,15 @@ export function NewProfileScreen() {
<FormField
label={t('profiles.descriptionLabel')}
placeholder={t('profiles.descriptionPlaceholder')}
error={descriptionError ?? undefined}
multiline
textAlignVertical="top"
className="min-h-20 leading-5"
onChangeText={value => {
descriptionRef.current = value;
if (descriptionError !== null && validateProfileDescription(value) === null) {
setDescriptionError(null);
}
}}
/>

Expand Down
66 changes: 19 additions & 47 deletions apps/mobile/src/components/profiles/profile-overview-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type OverviewSectionKey,
overviewSectionRows,
} from '@/components/profiles/profile-overview-model';
import { ProfileOverviewSkeleton } from '@/components/profiles/profile-overview-skeleton';
import { ProfileRepoPinsSection } from '@/components/profiles/profile-repo-pins-section';
import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
Expand All @@ -30,9 +31,8 @@ import {
Trash2,
} from '@/components/ui/icons';
import { PreferenceRow } from '@/components/ui/preference-row';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { validateProfileName } from '@/lib/agent-profile-forms';
import { validateProfileDescription, validateProfileName } from '@/lib/agent-profile-forms';
import {
type AgentProfileDetail,
useAgentProfile,
Expand Down Expand Up @@ -93,15 +93,25 @@ function ProfileMetadataForm({ profile, isSaving, onSave }: MetadataFormProps) {
const nameRef = useRef(profile.name);
const descriptionRef = useRef(profile.description ?? '');
const [nameError, setNameError] = useState<string | null>(null);
const [descriptionError, setDescriptionError] = useState<string | null>(null);

const submit = () => {
const name = nameRef.current.trim();
if (validateProfileName(name) !== null) {
setNameError(t('profiles.nameRequired'));
const nameIssue = validateProfileName(name);
if (nameIssue !== null) {
setNameError(
nameIssue === 'empty' ? t('profiles.nameRequired') : t('agentChat.newSession.nameTooLong')
);
return;
}
const description = descriptionRef.current.trim();
if (validateProfileDescription(description) !== null) {
setDescriptionError(t('agentChat.newSession.descriptionTooLong'));
return;
}
setNameError(null);
onSave({ name, description: descriptionRef.current.trim() });
setDescriptionError(null);
onSave({ name, description });
};

return (
Expand All @@ -125,12 +135,16 @@ function ProfileMetadataForm({ profile, isSaving, onSave }: MetadataFormProps) {
label={t('profiles.descriptionLabel')}
placeholder={t('profiles.descriptionPlaceholder')}
defaultValue={profile.description ?? ''}
error={descriptionError ?? undefined}
multiline
textAlignVertical="top"
className="min-h-20 leading-5"
disabled={isSaving}
onChangeText={value => {
descriptionRef.current = value;
if (descriptionError !== null && validateProfileDescription(value) === null) {
setDescriptionError(null);
}
}}
/>
<Button loading={isSaving} disabled={isSaving} onPress={submit}>
Expand All @@ -140,48 +154,6 @@ function ProfileMetadataForm({ profile, isSaving, onSave }: MetadataFormProps) {
);
}

/**
* Content-shaped loading rows in the same slots as the loaded screen (two
* fields, the default row, the six section rows, the delete button) so the
* swap to real content does not move anything.
*/
function ProfileOverviewSkeleton() {
return (
<View className="gap-6">
<View className="gap-4">
<View className="gap-1.5">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-[44px] w-full rounded-md" />
</View>
<View className="gap-1.5">
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="h-20 w-full rounded-md" />
</View>
<Skeleton className="h-[44px] w-full rounded-md" />
</View>
<Skeleton className="h-16 w-full rounded-lg" />
<View className="gap-3 rounded-lg border border-border p-3">
<View className="flex-row items-center justify-between gap-2">
<Skeleton className="h-4 w-40 rounded" />
<Skeleton className="h-9 w-28 rounded-md" />
</View>
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-9 w-full rounded-md" />
</View>
<View className="gap-1">
<Skeleton className="h-4 w-28 rounded" />
<Skeleton className="h-[54px] w-full rounded-lg" />
<Skeleton className="h-[54px] w-full rounded-lg" />
<Skeleton className="h-[54px] w-full rounded-lg" />
<Skeleton className="h-[54px] w-full rounded-lg" />
<Skeleton className="h-[54px] w-full rounded-lg" />
<Skeleton className="h-[54px] w-full rounded-lg" />
</View>
<Skeleton className="h-[44px] w-full rounded-md" />
</View>
);
}

export function ProfileOverviewScreen({
profileId,
organizationId,
Expand Down
45 changes: 45 additions & 0 deletions apps/mobile/src/components/profiles/profile-overview-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { View } from 'react-native';

import { Skeleton } from '@/components/ui/skeleton';

/**
* Content-shaped loading rows in the same slots as the loaded screen (two
* fields, the default row, the six section rows, the delete button) so the
* swap to real content does not move anything.
*/
export function ProfileOverviewSkeleton() {
return (
<View className="gap-6">
<View className="gap-4">
<View className="gap-1.5">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-[44px] w-full rounded-md" />
</View>
<View className="gap-1.5">
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="h-20 w-full rounded-md" />
</View>
<Skeleton className="h-[44px] w-full rounded-md" />
</View>
<Skeleton className="h-16 w-full rounded-lg" />
<View className="gap-3 rounded-lg border border-border p-3">
<View className="flex-row items-center justify-between gap-2">
<Skeleton className="h-4 w-40 rounded" />
<Skeleton className="h-9 w-28 rounded-md" />
</View>
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-9 w-full rounded-md" />
</View>
<View className="gap-1">
<Skeleton className="h-4 w-28 rounded" />
<Skeleton className="h-[54px] w-full rounded-lg" />
<Skeleton className="h-[54px] w-full rounded-lg" />
<Skeleton className="h-[54px] w-full rounded-lg" />
<Skeleton className="h-[54px] w-full rounded-lg" />
<Skeleton className="h-[54px] w-full rounded-lg" />
<Skeleton className="h-[54px] w-full rounded-lg" />
</View>
<Skeleton className="h-[44px] w-full rounded-md" />
</View>
);
}
4 changes: 2 additions & 2 deletions apps/mobile/src/i18n/locales/fr.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions apps/mobile/src/i18n/locales/ha.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions apps/mobile/src/lib/agent-profile-forms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
parseSkillFrontmatter,
removeCommand,
replaceCommand,
validateProfileDescription,
validateProfileName,
validateSkillInput,
} from '@/lib/agent-profile-forms';
Expand All @@ -30,6 +31,24 @@ describe('validateProfileName', () => {
});
});

describe('validateProfileDescription', () => {
it('accepts a trimmed description within the 500 bound', () => {
expect(validateProfileDescription('Reviews a diff')).toBeNull();
expect(validateProfileDescription(' padded ')).toBeNull();
expect(validateProfileDescription('')).toBeNull();
expect(validateProfileDescription('a'.repeat(500))).toBeNull();
});

it('reports a description longer than 500 characters', () => {
expect(validateProfileDescription('a'.repeat(501))).toBe('too-long');
});

it('measures the trimmed length, not the raw length', () => {
expect(validateProfileDescription(` ${'a'.repeat(500)} `)).toBeNull();
expect(validateProfileDescription(` ${'a'.repeat(501)} `)).toBe('too-long');
});
});

describe('parseSkillFrontmatter', () => {
it('parses unquoted name and description', () => {
const markdown = ['---', 'name: code-review', 'description: Reviews a diff', '---', ''].join(
Expand Down
14 changes: 14 additions & 0 deletions apps/mobile/src/lib/agent-profile-forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
/** Server bound: `ProfileNameSchema`'s `z.string().min(1).max(100)`. */
const PROFILE_NAME_MAX_LENGTH = 100;

/** Server bound: `ProfileDescriptionSchema`'s `z.string().max(500)`. */
const PROFILE_DESCRIPTION_MAX_LENGTH = 500;

/** Server bound: `VarSchema`'s `z.string().min(1).max(256)`. */
export const VARIABLE_KEY_MAX_LENGTH = 256;

Expand Down Expand Up @@ -42,6 +45,17 @@ export function validateProfileName(value: string): ProfileNameError | null {
return null;
}

export type ProfileDescriptionError = 'too-long';

/**
* Validate a profile description against the server's `max(500)`. Descriptions
* are trimmed first, matching the form's trim on submit. Returns an error code,
* or `null` when valid.
*/
export function validateProfileDescription(value: string): ProfileDescriptionError | null {
Comment thread
iscekic marked this conversation as resolved.
return value.trim().length > PROFILE_DESCRIPTION_MAX_LENGTH ? 'too-long' : null;
}

/**
* Parse frontmatter `name` and `description` out of a SKILL.md body.
* Ported from the web editor's minimal parser
Expand Down
Loading