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
53 changes: 46 additions & 7 deletions frontend/ai.client/src/app/artifacts/artifact-library.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,23 @@ <h2 class="mt-3 text-sm/6 font-medium text-gray-900 dark:text-white">No artifact
<span class="sr-only">Open {{ row.title }}</span>
</button>
@if (row.owned; as item) {
<!-- Share pins this row's version, which in the library is
always HEAD — the list is one row per artifact, not per
version. The dialog captions the version before it
creates anything. -->
<button
type="button"
(click)="share(item)"
[appTooltip]="'Share'"
appTooltipPosition="top"
class="grid size-8 place-items-center rounded-2xl text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200"
>
<ng-icon name="heroArrowUpOnSquare" class="size-4" aria-hidden="true" />
<span class="sr-only"
>Share {{ item.title || 'Untitled artifact' }}, version
{{ item.version }}</span
>
</button>
<button
type="button"
(click)="rename(item)"
Expand Down Expand Up @@ -419,16 +436,25 @@ <h2 class="text-sm/6 font-semibold text-gray-900 dark:text-white">
}
</p>

<!-- Four controls in one row: labelled Open + Conversation need
~290px, and three columns give the card far less than that
on a narrow window (~166px at 1080px), so the two text
labels drop to `sr-only` below 19rem — kept in the
<!-- Five controls in one row: labelled Open + Conversation plus
three icons need 326px, and three columns give the card far
less than that on a narrow window (~166px at 1080px), so the
two text labels drop to `sr-only` below 21rem — kept in the
accessible name, since these controls have no aria-label of
their own and would otherwise become nameless icons, with
[appTooltip] standing in for the text a sighted user can no
longer see.

19rem is measured against the container's CONTENT box, which
⚠️ 21rem, not the 19rem this started at. 19rem was tuned for
FOUR controls and fit with zero slack: at three columns on a
1280px window the container measures exactly 306px and the
labelled row needed exactly 306px. Adding Share pushed the
row to 326px while leaving it 2px above the collapse
threshold, so the labels stayed visible and the last icon
was clipped. Measured in the browser, not derived — any
further control added to this row has to re-measure it.

21rem is measured against the container's CONTENT box, which
is what an `inline-size` container query reports — the
body's `p-5` means the card is 40px wider than the number
here. Sizing the threshold off the card (the intuitive
Expand All @@ -445,7 +471,7 @@ <h2 class="text-sm/6 font-semibold text-gray-900 dark:text-white">
class="inline-flex items-center gap-1.5 rounded-2xl px-2.5 py-1.5 text-sm/6 font-medium text-gray-700 hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:text-gray-300 dark:hover:bg-gray-700"
>
<ng-icon name="heroEye" class="size-4" aria-hidden="true" />
<span class="@max-[19rem]:sr-only">Open</span>
<span class="@max-[21rem]:sr-only">Open</span>
</button>
@if (row.kind === 'owned' && row.sessionId) {
<a
Expand All @@ -455,11 +481,24 @@ <h2 class="text-sm/6 font-semibold text-gray-900 dark:text-white">
class="inline-flex items-center gap-1.5 rounded-2xl px-2.5 py-1.5 text-sm/6 font-medium text-gray-700 hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:text-gray-300 dark:hover:bg-gray-700"
>
<ng-icon name="heroChatBubbleLeftRight" class="size-4" aria-hidden="true" />
<span class="@max-[19rem]:sr-only">Conversation</span>
<span class="@max-[21rem]:sr-only">Conversation</span>
</a>
}
@if (row.owned; as item) {
<div class="ml-auto flex items-center gap-1">
<button
type="button"
(click)="share(item)"
[appTooltip]="'Share'"
appTooltipPosition="top"
class="grid size-8 place-items-center rounded-2xl text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200"
>
<ng-icon name="heroArrowUpOnSquare" class="size-4" aria-hidden="true" />
<span class="sr-only"
>Share {{ item.title || 'Untitled artifact' }}, version
{{ item.version }}</span
>
</button>
<button
type="button"
(click)="rename(item)"
Expand Down
90 changes: 87 additions & 3 deletions frontend/ai.client/src/app/artifacts/artifact-library.page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
} from '../session/services/artifacts/artifact-share.service';
import { LocalSettingsService, type ViewMode } from '../services/local-settings.service';
import { ToastService } from '../services/toast/toast.service';
import { UserService } from '../auth/user.service';
import { ArtifactShareModalComponent } from '../session/components/message-list/components/artifact/artifact-share-modal.component';

function stubShared(
overrides: Partial<SharedWithMeArtifact> = {},
Expand Down Expand Up @@ -101,6 +103,13 @@ describe('ArtifactLibraryPage', () => {
{ provide: LocalSettingsService, useValue: mockSettings },
{ provide: ToastService, useValue: mockToast },
{ provide: Dialog, useValue: mockDialog },
// Stubbed rather than real: the real one computes off SessionService,
// which would drag HTTP into a page test that only needs an address
// to seed the share dialog's allowlist with.
{
provide: UserService,
useValue: { currentUser: () => ({ email: 'me@x.com' }) },
},
],
});
});
Expand Down Expand Up @@ -153,6 +162,7 @@ describe('ArtifactLibraryPage', () => {
totalCount: () => number;
tabCount: () => number;
load: () => Promise<void>;
share: (item: LibraryArtifact) => void;
rename: (item: LibraryArtifact) => Promise<void>;
confirmDelete: (item: LibraryArtifact) => Promise<void>;
busy: () => string | null;
Expand Down Expand Up @@ -631,6 +641,73 @@ describe('ArtifactLibraryPage', () => {
});
});

describe('share', () => {
it('opens the share dialog pinned to the row\'s version', async () => {
// The library lists HEAD, one row per artifact, so this is the
// version the dialog must caption and pin. A share never follows
// HEAD, so getting this wrong would silently share the wrong bytes.
mockHttp.listLibrary.mockResolvedValue([
stubArtifact({ artifactId: 'a', version: 4, title: 'Quarterly plan' }),
]);
const c = api(await createComponent());

c.share(c.items()[0]);

expect(mockDialog.open).toHaveBeenCalledWith(
ArtifactShareModalComponent,
{
data: {
artifactId: 'a',
version: 4,
title: 'Quarterly plan',
ownerEmail: 'me@x.com',
},
},
);
});

it('offers share on your own rows and not on received ones', async () => {
// A received artifact has no artifact id — the share id is the only
// handle on it — so there is nothing to re-share. The button must
// not render rather than render and fail.
mockHttp.listLibrary.mockResolvedValue([stubArtifact({ title: 'Mine' })]);
mockShares.listSharedWithMe.mockResolvedValue({
artifacts: [stubShared({ title: 'Theirs' })],
nextCursor: null,
});
const fixture = await createComponent();
const host = fixture.nativeElement as HTMLElement;

const shareLabels = [...host.querySelectorAll('li .sr-only')]
.map((el) => (el.textContent ?? '').trim())
.filter((t) => t.startsWith('Share '));

expect(shareLabels).toHaveLength(1);
expect(shareLabels[0]).toContain('Mine');
expect(shareLabels[0]).not.toContain('Theirs');
});

it('carries the version in the accessible name, in both views', async () => {
// The icon is the same glyph as the conversation share, and the row
// is captioned with a version only when it is > 1 — so the version
// the link will pin has to be in the name itself.
mockHttp.listLibrary.mockResolvedValue([
stubArtifact({ version: 4, title: 'Quarterly plan' }),
]);
for (const mode of ['list', 'grid'] as ViewMode[]) {
mockSettings.artifactsViewMode.set(mode);
const fixture = await createComponent();
const host = fixture.nativeElement as HTMLElement;
const label = [...host.querySelectorAll('.sr-only')]
.map((el) => (el.textContent ?? '').replace(/\s+/g, ' ').trim())
.find((t) => t.startsWith('Share '));
expect(label, `view mode: ${mode}`).toBe(
'Share Quarterly plan, version 4',
);
}
});
});

describe('delete', () => {
it('removes the row only after the request succeeds', async () => {
mockHttp.listLibrary.mockResolvedValue([
Expand Down Expand Up @@ -688,12 +765,19 @@ describe('ArtifactLibraryPage', () => {

describe('grid card footer', () => {
it('keeps both button labels in the DOM when they collapse', async () => {
// The footer carries four controls and the card is ~13rem wide at
// The footer carries five controls and the card is ~13rem wide at
// three columns on a 1080px window, so a container query drops the
// "Open" / "Conversation" text below 19rem. It must be dropped to
// "Open" / "Conversation" text below 21rem. It must be dropped to
// `sr-only`, never `hidden`: these buttons have no aria-label, so
// removing the text would leave them with no accessible name at
// exactly the width where they become bare icons.
//
// ⚠️ The threshold is a measured number, not a style choice: the
// labelled row needs 326px and the container is 306px at three
// columns, so a threshold below 21rem leaves the labels showing at
// a width where the last icon is clipped. This assertion is the
// only thing standing between the next control added to this row
// and that regression — re-measure in a browser before changing it.
mockSettings.artifactsViewMode.set('grid');
mockHttp.listLibrary.mockResolvedValue([stubArtifact()]);
const fixture = await createComponent();
Expand All @@ -704,7 +788,7 @@ describe('ArtifactLibraryPage', () => {
);
expect(labels).toHaveLength(2);
for (const label of labels) {
expect(label.className).toContain('@max-[19rem]:sr-only');
expect(label.className).toContain('@max-[21rem]:sr-only');
expect(label.className).not.toContain('hidden');
}
});
Expand Down
40 changes: 39 additions & 1 deletion frontend/ai.client/src/app/artifacts/artifact-library.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Dialog } from '@angular/cdk/dialog';
import { firstValueFrom } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
heroArrowUpOnSquare,
heroBars3,
heroChatBubbleLeftRight,
heroChevronDown,
Expand Down Expand Up @@ -50,6 +51,11 @@ import {
type RenameArtifactDialogData,
type RenameArtifactDialogResult,
} from './components/rename-artifact-dialog.component';
import {
ArtifactShareModalComponent,
type ArtifactShareModalData,
} from '../session/components/message-list/components/artifact/artifact-share-modal.component';
import { UserService } from '../auth/user.service';

/**
* Presentation for one artifact content type.
Expand Down Expand Up @@ -185,7 +191,8 @@ interface LibraryRow {
changeDetection: ChangeDetectionStrategy.OnPush,
viewProviders: [
provideIcons({
heroBars3,
heroArrowUpOnSquare,
heroBars3,
heroChatBubbleLeftRight,
heroChevronDown,
heroCodeBracket,
Expand All @@ -208,6 +215,7 @@ export class ArtifactLibraryPage {
private readonly toast = inject(ToastService);
private readonly router = inject(Router);
private readonly dialog = inject(Dialog);
private readonly userService = inject(UserService);

protected readonly items = signal<LibraryArtifact[]>([]);

Expand Down Expand Up @@ -551,6 +559,36 @@ export class ArtifactLibraryPage {
void this.router.navigate([...row.route]);
}

/**
* Share an artifact from the library.
*
* The same dialog the in-conversation card opens — create and revoke
* both live in it — so there is exactly one place in the app that
* knows what an artifact share is.
*
* ⚠️ **It pins `item.version`, which here is always HEAD.** The library
* lists one row per artifact, not per version, so this shares the
* latest version and a later version of the same artifact will not
* follow the link. That is the share contract, not a shortcut: shares
* are immutable by design, and the dialog captions the version it is
* about to pin so the difference is visible before anything is
* created.
*
* Only offered on owned rows. A received artifact has no artifact id
* to share — the share id is the only handle you have on it — and
* re-sharing someone else's grant is not a thing this model supports.
*/
protected share(item: LibraryArtifact): void {
this.dialog.open(ArtifactShareModalComponent, {
data: {
artifactId: item.artifactId,
version: item.version,
title: item.title,
ownerEmail: this.userService.currentUser()?.email ?? '',
} as ArtifactShareModalData,
});
}

/**
* Rename an artifact.
*
Expand Down
16 changes: 16 additions & 0 deletions frontend/ai.client/src/app/artifacts/artifact-view.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,22 @@ <h1 class="truncate text-sm/6 font-semibold text-gray-900 dark:text-gray-100">
<span class="sr-only">Open this artifact in a new tab</span>
</button>

<!-- Only reachable on your own artifacts: this page resolves out of
your library partition, so someone else's id renders "not found"
and never gets here. The version it shares is the one on screen,
and shares are immutable — a later version will not follow the
link, which the dialog says before it creates one. -->
<button
type="button"
class="flex size-8 shrink-0 items-center justify-center rounded-2xl text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-accessible dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
[attr.aria-label]="'Share this artifact, version ' + a.version"
[appTooltip]="'Share'"
appTooltipPosition="bottom"
(click)="share()"
>
<ng-icon name="heroArrowUpOnSquare" class="text-lg" aria-hidden="true" />
</button>

<button
type="button"
class="flex size-8 shrink-0 items-center justify-center rounded-2xl text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-accessible disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
Expand Down
Loading