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
86 changes: 86 additions & 0 deletions pages/app-layout/genai-context-panel.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';

import { AppLayout, Box, ContentLayout, Header, SpaceBetween } from '~components';
import { AppLayoutProps } from '~components/app-layout';
import {
createGenaiContextPanelDrawer,
GenaiContextPanelProps,
} from '~components/internal/components/genai-context-panel';

import appLayoutLabels from './utils/labels';

// [WIP / v0 — AWSUI-52880] Demonstrates the opt-in Global GenAI context panel
// composed into an AppLayout drawer.
const MODELS: ReadonlyArray<GenaiContextPanelProps.Model> = [
{ id: 'claude-sonnet', label: 'Claude Sonnet', description: 'Balanced quality and latency' },
{ id: 'claude-haiku', label: 'Claude Haiku', description: 'Fastest, lowest cost' },
{ id: 'nova-pro', label: 'Amazon Nova Pro', description: 'Multimodal reasoning' },
];

function makeFile(name: string, contents: string) {
return new File([contents], name, { type: 'text/plain' });
}

export default function GenaiContextPanelPage() {
const [activeDrawerId, setActiveDrawerId] = useState<string | null>('genai-context-panel');
const [systemPrompt, setSystemPrompt] = useState(
'You are a helpful AWS assistant. Prefer concise answers and cite the docs you use.'
);
const [selectedModelId, setSelectedModelId] = useState<string | null>('claude-sonnet');
const [contextFiles, setContextFiles] = useState<File[]>([
makeFile('architecture.md', '# Architecture\nsome notes'),
makeFile('costs.csv', 'service,cost\n'),
]);

const drawer = createGenaiContextPanelDrawer({
systemPrompt,
onSystemPromptChange: ({ detail }) => setSystemPrompt(detail.value),
models: MODELS,
selectedModelId,
onSelectedModelChange: ({ detail }) => setSelectedModelId(detail.selectedModelId),
contextFiles,
onContextFilesChange: ({ detail }) => setContextFiles(detail.files),
badge: contextFiles.length > 0,
i18nStrings: {
headerText: 'GenAI context',
headerDescription: 'Configure how the assistant behaves for this conversation.',
systemPromptDescription: 'These instructions are sent with every message.',
modelSelectorDescription: 'Choose which model responds.',
contextFilesDescription: 'Files the assistant can reference.',
},
});

return (
<AppLayout
ariaLabels={appLayoutLabels}
contentType="default"
drawers={[drawer]}
activeDrawerId={activeDrawerId}
onDrawerChange={({ detail }) => setActiveDrawerId(detail.activeDrawerId)}
content={
<ContentLayout header={<Header variant="h1">Global GenAI context panel (WIP v0)</Header>}>
<SpaceBetween size="l">
<Box variant="p">
Open the <b>GenAI context</b> drawer from the right-hand toolbar to view and edit the system prompt, pick
a model, and manage the files currently in the assistant&apos;s context.
</Box>
<Box variant="p" data-testid="live-system-prompt">
Current system prompt: {systemPrompt}
</Box>
<Box variant="p" data-testid="live-model">
Current model: {selectedModelId}
</Box>
<Box variant="p" data-testid="live-file-count">
Files in context: {contextFiles.length}
</Box>
</SpaceBetween>
</ContentLayout>
}
/>
);
}

// exported for type-checking convenience in this demo
export type { AppLayoutProps };
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';

import GenaiContextPanel, {
createGenaiContextPanelDrawer,
} from '../../../../../lib/components/internal/components/genai-context-panel';

describe('GenaiContextPanel', () => {
test('renders the header text and current system prompt value', () => {
render(<GenaiContextPanel systemPrompt="be concise" i18nStrings={{ headerText: 'GenAI context' }} />);

expect(screen.getByText('GenAI context')).toBeInTheDocument();
expect(screen.getByDisplayValue('be concise')).toBeInTheDocument();
});

test('fires onSystemPromptChange when the prompt is edited', () => {
const onSystemPromptChange = jest.fn();
render(<GenaiContextPanel systemPrompt="" onSystemPromptChange={onSystemPromptChange} />);

fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello' } });

expect(onSystemPromptChange).toHaveBeenCalledTimes(1);
expect(onSystemPromptChange.mock.calls[0][0].detail).toEqual({ value: 'hello' });
});

test('shows an empty state when there are no context files', () => {
render(<GenaiContextPanel contextFiles={[]} />);

expect(screen.getByText('No files added to the assistant context yet.')).toBeInTheDocument();
});

test('renders a model selector only when models are provided', () => {
const { rerender } = render(<GenaiContextPanel />);
expect(screen.queryByText('Model')).not.toBeInTheDocument();

rerender(<GenaiContextPanel models={[{ id: 'm1', label: 'Model One' }]} selectedModelId="m1" />);
expect(screen.getByText('Model')).toBeInTheDocument();
});

test('fires onContextFilesChange without the removed file when a token is dismissed', () => {
const onContextFilesChange = jest.fn();
const fileA = new File(['a'], 'a.txt', { type: 'text/plain' });
const fileB = new File(['b'], 'b.txt', { type: 'text/plain' });

render(<GenaiContextPanel contextFiles={[fileA, fileB]} onContextFilesChange={onContextFilesChange} />);

fireEvent.click(screen.getByLabelText('Remove a.txt (file 1)'));

expect(onContextFilesChange).toHaveBeenCalledTimes(1);
expect(onContextFilesChange.mock.calls[0][0].detail.files).toEqual([fileB]);
});
});

describe('createGenaiContextPanelDrawer', () => {
test('returns a drawer item with sensible defaults', () => {
const drawer = createGenaiContextPanelDrawer();

expect(drawer.id).toBe('genai-context-panel');
expect(drawer.resizable).toBe(true);
expect(drawer.defaultSize).toBe(360);
expect(drawer.trigger?.iconName).toBe('gen-ai');
expect(drawer.ariaLabels.drawerName).toBe('GenAI context');
expect(drawer.content).toBeTruthy();
});

test('uses the header text as the drawer name and forwards a custom id/badge', () => {
const drawer = createGenaiContextPanelDrawer({
id: 'my-drawer',
badge: true,
i18nStrings: { headerText: 'Assistant context' },
});

expect(drawer.id).toBe('my-drawer');
expect(drawer.badge).toBe(true);
expect(drawer.ariaLabels.drawerName).toBe('Assistant context');
});
});
177 changes: 177 additions & 0 deletions src/internal/components/genai-context-panel/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';

import { AppLayoutProps } from '../../../app-layout/interfaces';
import InternalBox from '../../../box/internal';
import InternalFileTokenGroup from '../../../file-token-group/internal';
import InternalFormField from '../../../form-field/internal';
import InternalHeader from '../../../header/internal';
import InternalPromptInput from '../../../prompt-input/internal';
import { SelectProps } from '../../../select/interfaces';
import InternalSelect from '../../../select/internal';
import InternalSpaceBetween from '../../../space-between/internal';
import { fireNonCancelableEvent } from '../../events';
import { GenaiContextPanelProps } from './interfaces';

export { GenaiContextPanelProps };

const DEFAULT_MIN_ROWS = 3;
const DEFAULT_MAX_ROWS = 8;

/**
* [WIP / v0 — AWSUI-52880] Global GenAI context panel.
*
* A controlled, opt-in composition of existing Cloudscape primitives that gives a
* generative-AI assistant a single place to surface and edit its "context":
* the system prompt, the active model, and the files currently in scope.
*
* It renders plain content and is meant to be dropped into the `content` slot of
* an AppLayout drawer. Use {@link createGenaiContextPanelDrawer} to build a ready
* to use `AppLayoutProps.Drawer` item.
*/
export default function GenaiContextPanel({
systemPrompt = '',
systemPromptPlaceholder,
systemPromptReadOnly,
systemPromptMinRows = DEFAULT_MIN_ROWS,
systemPromptMaxRows = DEFAULT_MAX_ROWS,
onSystemPromptChange,
models = [],
selectedModelId = null,
onSelectedModelChange,
contextFiles = [],
onContextFilesChange,
i18nStrings = {},
}: GenaiContextPanelProps) {
const modelOptions: Array<SelectProps.Option> = models.map(model => ({
value: model.id,
label: model.label,
description: model.description,
}));
const selectedOption = modelOptions.find(option => option.value === selectedModelId) ?? null;

const handleDismissFile = (fileIndex: number) => {
const nextFiles = contextFiles.filter((_, index) => index !== fileIndex);
fireNonCancelableEvent(onContextFilesChange, { files: nextFiles });
};

return (
<InternalSpaceBetween size="l">
<InternalHeader variant="h2" description={i18nStrings.headerDescription}>
{i18nStrings.headerText ?? 'Context'}
</InternalHeader>

<InternalFormField
label={i18nStrings.systemPromptLabel ?? 'System prompt'}
description={i18nStrings.systemPromptDescription}
stretch={true}
>
<InternalPromptInput
value={systemPrompt}
placeholder={systemPromptPlaceholder}
readOnly={systemPromptReadOnly}
minRows={systemPromptMinRows}
maxRows={systemPromptMaxRows}
ariaLabel={i18nStrings.systemPromptLabel ?? 'System prompt'}
onChange={({ detail }) => fireNonCancelableEvent(onSystemPromptChange, { value: detail.value })}
/>
</InternalFormField>

{models.length > 0 && (
<InternalFormField
label={i18nStrings.modelSelectorLabel ?? 'Model'}
description={i18nStrings.modelSelectorDescription}
stretch={true}
>
<InternalSelect
selectedOption={selectedOption}
options={modelOptions}
placeholder={i18nStrings.modelSelectorPlaceholder ?? 'Select a model'}
onChange={({ detail }) =>
fireNonCancelableEvent(onSelectedModelChange, {
selectedModelId: detail.selectedOption.value ?? '',
})
}
/>
</InternalFormField>
)}

<InternalFormField
label={i18nStrings.contextFilesLabel ?? 'Files in context'}
description={i18nStrings.contextFilesDescription}
stretch={true}
>
{contextFiles.length === 0 ? (
<InternalBox color="text-body-secondary">
{i18nStrings.contextFilesEmptyText ?? 'No files added to the assistant context yet.'}
</InternalBox>
) : (
<InternalFileTokenGroup
alignment="vertical"
items={contextFiles.map(file => ({ file }))}
showFileSize={true}
showFileLastModified={true}
onDismiss={({ detail }) => handleDismissFile(detail.fileIndex)}
i18nStrings={{
removeFileAriaLabel:
i18nStrings.removeFileAriaLabel ??
((fileIndex, fileName) => `Remove ${fileName} (file ${fileIndex + 1})`),
}}
/>
)}
</InternalFormField>
</InternalSpaceBetween>
);
}

export interface CreateGenaiContextPanelDrawerOptions extends GenaiContextPanelProps {
/**
* The id of the drawer. Defaults to `genai-context-panel`.
*/
id?: string;

/**
* ARIA labels for the drawer. When omitted, the header text (or a sensible
* default) is used for the drawer name.
*/
ariaLabels?: AppLayoutProps.DrawerAriaLabels;

/**
* Adds a badge to the drawer trigger.
*/
badge?: boolean;

/**
* Whether the drawer is resizable. Defaults to `true`.
*/
resizable?: boolean;

/**
* The starting width of the drawer in pixels. Defaults to `360`.
*/
defaultSize?: number;
}

/**
* Builds an `AppLayoutProps.Drawer` item that renders the GenAI context panel.
* Spread the result into the AppLayout `drawers` array — this keeps the feature
* fully additive and opt-in.
*/
export function createGenaiContextPanelDrawer(
options: CreateGenaiContextPanelDrawerOptions = {}
): AppLayoutProps.Drawer {
const { id = 'genai-context-panel', ariaLabels, badge, resizable = true, defaultSize = 360, ...panelProps } = options;

const drawerName = panelProps.i18nStrings?.headerText ?? 'GenAI context';

return {
id,
badge,
resizable,
defaultSize,
trigger: { iconName: 'gen-ai' },
ariaLabels: ariaLabels ?? { drawerName, closeButton: 'Close', triggerButton: drawerName, resizeHandle: 'Resize' },
content: <GenaiContextPanel {...panelProps} />,
};
}
Loading
Loading