mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
fix(skills): simplify source rows, errors, and actions (#16310)
## Thinking Path > - Paperclip manages agent skills from GitHub repositories. > - Skills → Sources shows saved repositories and their sync status. > - A saved source gained a second repository row when other projects used the same repository and branch. > - That row repeated the GitHub icon and branch, and it exposed project names that did not help with source management. > - The page also repeated the Installed navigation link and showed refresh errors as separate lines of text. > - Connection management also appeared under each source, where it competed with source-specific actions. > - This pull request shows each saved source once, groups error details, and moves connection management into the menu beside Import from GitHub. > - The result is a clearer source list that keeps import and recovery actions available. ## Linked Issues or Issue Description **What happened?** When a saved source shared its repository and branch with other projects, Skills → Sources displayed a second GitHub header and the names of those projects. The page also displayed an extra Installed skills link and repeated Manage GitHub connection under source rows. A failed refresh placed its timestamp and error on separate lines. **Expected behavior** Show one row for each saved source. Keep project names out of that row. Keep the additional import action available in More. Put connection management in the page actions menu beside Import from GitHub. Present refresh errors as one readable notice with the time and review action. **Steps to reproduce** 1. Open Skills → Sources in a company with one saved GitHub source and another project that uses the same repository and branch. 2. Observe the repeated repository row and project names. 3. Open the Storybook journey `Existing source, more projects` to inspect the same state. Open `Retained skills and retry` to inspect a failed refresh. **Paperclip version or commit** The issue is present on base commit `739f9e40a4`. **Deployment mode** Built from source. The issue was also observed in a staging workspace. ## What Changed - Remove project names, the duplicate repository row, and the redundant Installed skills link from saved source rows. - Put imports for additional projects in the source More menu. Show brief import progress in the source metadata. - Group source errors, attempt time, and the review link in a compact notice. Add a Storybook journey and browser coverage for the shared-repository state. - Add a page actions menu beside Import from GitHub. Put Manage GitHub connection there and remove the repeated inline links. Cover the menu and keyboard focus in the Storybook browser suite. ## Verification - `pnpm -r typecheck`, `pnpm build`, `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node scripts/storybook-catalog.mjs --check` passed locally. The Skills sources unit suite passed 22 tests. - All 18 repository-skills Storybook browser tests passed, including the shared-source import, connection menu, mobile, and failed-refresh journeys. - A local full `pnpm test:run` was stopped after a server runner-API test timed out. An isolated diagnostic identified macOS embedded PostgreSQL shared-memory exhaustion. The PR passed all 55 CI checks, including server test shards and end-to-end tests. - Open the Storybook journeys `Existing source, more projects`, `Automatically imported skills`, and `Retained skills and retry` to inspect the compact source row, page actions menu, and grouped error notice. ## Risks Low risk. The visible import action for projects that share a saved source moves to More. Connection management moves to a page menu and opens the Apps catalog. People must open those menus to find the actions. The import API and access checks do not change. > I checked `ROADMAP.md`. This source-list correction does not duplicate planned core work. ## Model Used OpenAI Codex, GPT-6 family. The task runtime did not expose an exact serving model ID or context-window size. The model used tool calls, code edits, and browser verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5b29b9062e
commit
33d24e48ad
9 files changed
+106
-32
No files matched your search
@@ -69,9 +69,11 @@ Manual: Enable the skill for one test agent and leave another without access. Ru
|
||||
### Project repository source behavior
|
||||
|
||||
New GitHub project repositories import skills automatically. Skills → Sources lists
|
||||
accessible legacy repositories with Import skills, shows source/project provenance,
|
||||
groups matching repository/ref workspaces into one row, and shows queued/importing feedback,
|
||||
and provides Refresh and Automatic updates in its source menu. Verify future additions,
|
||||
accessible legacy repositories with Import skills, shows each saved repository once with
|
||||
its access scope but without project names, and groups matching repository/ref workspaces.
|
||||
Additional legacy imports for an existing source live in its More menu. Import progress
|
||||
appears in the source metadata; Refresh and Automatic updates are also available.
|
||||
Verify future additions,
|
||||
retained exclusions, error recovery and reload persistence. Private-only sources must
|
||||
be absent for unrelated people/agents; access through either originating project is sufficient.
|
||||
|
||||
|
||||
@@ -12,6 +12,28 @@ test('groups matching project repositories and keeps progress visible through ad
|
||||
await expect(page.getByRole('button', { name: 'Import skills', exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('keeps an existing source compact while allowing project import from its menu', async ({ page }, info) => {
|
||||
await page.goto(story('existing-source-more-projects'));
|
||||
const source = page.getByRole('region', { name: 'acme/team-skills', exact: true });
|
||||
await expect(source.getByRole('link', { name: 'acme/team-skills', exact: true })).toHaveCount(1);
|
||||
await expect(source).not.toContainText('Paperclip Cloud');
|
||||
await expect(source).not.toContainText('Sentry');
|
||||
await expect(source).not.toContainText('Other projects with this repo');
|
||||
await expect(page.getByRole('link', { name: 'Installed skills', exact: true })).toHaveCount(0);
|
||||
await expect(source.getByRole('button', { name: 'Import skills', exact: true })).toHaveCount(0);
|
||||
await page.screenshot({ path: info.outputPath('existing-source-more-projects.png') });
|
||||
await source.getByRole('button', { name: 'More actions for acme/team-skills', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'Import project skills', exact: true }).click();
|
||||
await expect(source.getByRole('status')).toContainText('Importing skills');
|
||||
await expect(source.getByRole('status')).toHaveCount(0);
|
||||
await source.getByRole('button', { name: 'More actions for acme/team-skills', exact: true }).click();
|
||||
await expect(page.getByRole('menuitem', { name: /^(Import (remaining )?project skills|Retry project import)$/ })).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(source).toBeVisible();
|
||||
await expect(source).not.toContainText('Paperclip Cloud');
|
||||
await expect(source).not.toContainText('Sentry');
|
||||
});
|
||||
|
||||
test('repairs a missing branch without prompting for another connection', async ({ page }) => {
|
||||
await page.goto(story('missing-branch'));
|
||||
await expect(page.getByRole('button', { name: 'Choose connection', exact: true })).toHaveCount(0);
|
||||
@@ -102,7 +124,7 @@ test('legacy adoption imports directly and survives navigation within the journe
|
||||
await expect(source).toBeVisible();
|
||||
await expect(source.getByText('Code review', { exact: true })).toBeVisible();
|
||||
await expect(action).toHaveCount(0);
|
||||
await page.getByRole('link', { name: 'Installed skills', exact: true }).click();
|
||||
await page.getByLabel('Skills', { exact: true }).getByRole('link', { name: 'Installed', exact: true }).click();
|
||||
await expect(source).toHaveCount(0);
|
||||
await expect(page.getByText('Code review', { exact: true }).first()).toBeVisible();
|
||||
await page.getByLabel('Skills', { exact: true }).getByRole('link', { name: 'Sources', exact: true }).click();
|
||||
@@ -118,11 +140,24 @@ test('background refresh imports an addition without clicking refresh', async ({
|
||||
await page.screenshot({ path: info.outputPath('automatic-update.png') });
|
||||
});
|
||||
|
||||
test('keeps GitHub connection management in the page actions menu', async ({ page }) => {
|
||||
await page.goto(story('synced'));
|
||||
const source = page.getByRole('region', { name: 'acme/team-skills', exact: true });
|
||||
await expect(source.getByRole('link', { name: 'Manage GitHub connection', exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'More source actions', exact: true }).click();
|
||||
const manageConnection = page.getByRole('menuitem', { name: 'Manage GitHub connection', exact: true });
|
||||
await expect(manageConnection).toBeVisible();
|
||||
await expect(manageConnection).toHaveAttribute('href', /\/apps$/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('button', { name: 'More source actions', exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
test('refresh failure retains content and retry recovers', async ({ page }, info) => {
|
||||
await page.goto(story('failed-refresh'));
|
||||
const source = page.getByRole('region', { name: 'acme/team-skills', exact: true });
|
||||
await expect(source.getByText('Code review', { exact: true })).toBeVisible();
|
||||
await expect(source.getByRole('alert')).toContainText('installed skills were kept');
|
||||
await page.screenshot({ path: info.outputPath('refresh-error.png') });
|
||||
await source.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
await expect(source.getByRole('button', { name: 'Refresh', exact: true })).toBeEnabled();
|
||||
await source.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import type { ProjectSkillRepository } from '@paperclipai/shared';
|
||||
import { GithubIcon } from '@/components/icons/github-icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from '@/lib/router';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
|
||||
export function ProjectSkillRepositoryRow({ repository, pending, backgroundPending, disabled, showRepository = true, onImport }: {
|
||||
export function ProjectSkillRepositoryRow({ repository, pending, backgroundPending, disabled, onImport }: {
|
||||
repository: ProjectSkillRepository;
|
||||
pending?: boolean;
|
||||
backgroundPending?: boolean;
|
||||
disabled?: boolean;
|
||||
showRepository?: boolean;
|
||||
onImport: () => void;
|
||||
}) {
|
||||
const actionPending = pending || repository.status === 'pending';
|
||||
@@ -18,9 +16,9 @@ export function ProjectSkillRepositoryRow({ repository, pending, backgroundPendi
|
||||
<div className="flex items-center gap-3">
|
||||
<GithubIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{showRepository && <a href={repository.repositoryUrl} target="_blank" rel="noreferrer" className="block truncate text-sm font-medium hover:underline">
|
||||
<a href={repository.repositoryUrl} target="_blank" rel="noreferrer" className="block truncate text-sm font-medium hover:underline">
|
||||
{repository.repositoryUrl.replace('https://github.com/', '')}
|
||||
</a>}
|
||||
</a>
|
||||
<p className="truncate text-xs text-muted-foreground">{repository.projectName} · {repository.trackingRef === 'HEAD' ? 'Default branch' : repository.trackingRef}{repository.projectVisibility === 'private' ? ' · Project access' : ''}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" disabled={actionPending || disabled} onClick={onImport}>
|
||||
@@ -30,6 +28,6 @@ export function ProjectSkillRepositoryRow({ repository, pending, backgroundPendi
|
||||
</div>
|
||||
{importing && <p role="status" className="ml-8 text-xs text-muted-foreground">{pending ? 'Starting import…' : repository.importState === 'importing'
|
||||
? 'Checking repository for skills… This can take a few minutes for large repositories.' : 'Import queued. Skills will appear here when ready.'}</p>}
|
||||
{repository.lastError && <p role="alert" className="text-sm text-destructive">{repository.lastError} <Link to="/apps" className="underline">Manage GitHub connection</Link></p>}
|
||||
{repository.lastError && <p role="alert" className="text-sm text-destructive">{repository.lastError}</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -119,16 +119,20 @@ describe('GitHub skill source import', () => {
|
||||
expect(document.querySelector('button[aria-label="Branch"]')?.textContent).toContain('master');
|
||||
});
|
||||
|
||||
it('keeps pending project links inside the installed source instead of duplicating its repository', async () => {
|
||||
it('keeps an existing source compact while project import is pending', async () => {
|
||||
context.sourceId = '';
|
||||
vi.mocked(skillSourcesApi.list).mockResolvedValue([{ ...sourceFixture(), trackingRef: 'HEAD' }]);
|
||||
vi.mocked(skillSourcesApi.list).mockResolvedValue([{ ...sourceFixture(), trackingRef: 'HEAD', projects: [{ id: 'paperclip', name: 'Paperclip', visibility: 'open', active: true }] }]);
|
||||
vi.mocked(skillSourcesApi.projectRepositories).mockResolvedValue([{ projectId: 'sentry', projectName: 'Sentry', projectVisibility: 'open',
|
||||
workspaceId: 'second-workspace', repositoryUrl: sourceFixture().repositoryUrl, trackingRef: 'HEAD', bindingId: 'binding', sourceId: null, status: 'pending', lastError: null }]);
|
||||
await mount();
|
||||
expect(document.querySelectorAll(`a[href="${sourceFixture().repositoryUrl}"]`)).toHaveLength(1);
|
||||
expect(document.querySelector('[aria-label="Project repositories"]')).toBeNull();
|
||||
expect(button('Importing…').disabled).toBe(true);
|
||||
expect(document.querySelector('[role="status"]')?.textContent).toContain('queued');
|
||||
expect(document.querySelector('[aria-label="Skills from Sentry"]')).toBeNull();
|
||||
expect(document.body.textContent).not.toContain('Other projects with this repo');
|
||||
expect(document.body.textContent).not.toContain('Sentry');
|
||||
expect(document.body.textContent).not.toContain('Paperclip');
|
||||
expect(document.querySelector('a[href="/skills"]')).toBeNull();
|
||||
expect(document.querySelector('[role="status"]')?.textContent).toContain('Importing skills');
|
||||
});
|
||||
|
||||
it('allows remaining project imports while another matching workspace is pending', async () => {
|
||||
|
||||
@@ -3,9 +3,9 @@ import { SkillSourceConnectionRepair } from "@/components/skill-studio/SkillSour
|
||||
import { SkillSourceBranchRepair } from "@/components/skill-studio/SkillSourceBranchRepair";
|
||||
import { SkillSourceSyncControls } from "@/components/skill-studio/SkillSourceSyncControls";
|
||||
import { GithubIcon } from "@/components/icons/github-icon";
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { RefreshCw, Plus, ExternalLink, Check, Lock, GitBranch, FileText, MoreVertical } from 'lucide-react';
|
||||
import { RefreshCw, Plus, ExternalLink, Check, Lock, GitBranch, FileText, MoreVertical, CircleAlert } from 'lucide-react';
|
||||
import { parseGitHubSkillRepositoryUrl, type SkillSourceDisconnectRequest, type SkillSource, type SkillSourceDiscovery, type SkillSourceRefreshResult, type SkillSourceScanProgress } from '@paperclipai/shared';
|
||||
import { Link, useNavigate, useParams } from '@/lib/router';
|
||||
import { useCompany } from '@/context/CompanyContext';
|
||||
@@ -85,9 +85,18 @@ export function SkillSources() {
|
||||
return <div className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 md:p-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div><h1 className="text-xl font-semibold">Skill sources</h1><p className="mt-1 text-sm text-muted-foreground">Skills from your repositories stay up to date automatically.</p></div>
|
||||
<Button onClick={() => navigate('/skills/sources/new')}><Plus className="size-4" />Import from GitHub</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => navigate('/skills/sources/new')}><Plus className="size-4" />Import from GitHub</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" aria-label="More source actions"><MoreVertical className="size-4" /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild><Link to="/apps">Manage GitHub connection</Link></DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<Link to="/skills" className="text-sm text-muted-foreground hover:text-foreground">Installed skills</Link>
|
||||
{query.isPending && <p role="status" className="text-sm text-muted-foreground">Loading sources…</p>}
|
||||
{query.error && <p role="alert" className="text-sm text-destructive">{query.error.message} <Button variant="ghost" size="sm" onClick={() => void query.refetch()}>Try again</Button></p>}
|
||||
{query.data?.length === 0 && availableProjects.length === 0 && <div className="flex flex-col items-start gap-3 py-8"><p className="text-sm text-muted-foreground">No repositories added yet. Import your skills to make them available in {selectedCompany?.name ?? 'this company'}.</p><Button variant="outline" onClick={() => navigate('/skills/sources/new')}>Import from GitHub</Button></div>}
|
||||
@@ -103,6 +112,7 @@ export function SkillSources() {
|
||||
const newCount = source.entries.filter(entry => entry.selection === 'new' && entry.present).length;
|
||||
const result = results[source.id];
|
||||
const projectGroup = availableProjects.find(group => groupSource(group)?.id === source.id);
|
||||
const importingProjectSkills = projectGroup && (projectGroup.hasPending || (adopt.isPending && adopt.variables?.key === projectGroup.key));
|
||||
const localError = result && result.message !== source.lastError
|
||||
&& (result.operation === 'settings' || !source.lastSuccessAt || new Date(source.lastSuccessAt).getTime() < result.failedAt)
|
||||
? result.message : null;
|
||||
@@ -110,8 +120,7 @@ export function SkillSources() {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex min-w-0 basis-full items-start gap-3 sm:flex-1 sm:basis-auto"><GithubIcon className="size-5 shrink-0 text-muted-foreground" /><div className="min-w-0 flex-1">
|
||||
<a href={source.repositoryUrl} target="_blank" rel="noreferrer" title={source.fullName} className="block truncate rounded-sm text-sm font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">{source.fullName}</a>
|
||||
<p className="truncate text-xs text-muted-foreground">{source.trackingRef === 'HEAD' ? 'Default branch' : source.trackingRef}{source.lastSuccessAt ? ` · Updated ${timeAgo(source.lastSuccessAt)}` : ''}{source.accessScope === 'projects' ? ' · Project access' : ''}</p>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">{source.projects?.map(project => <Link key={project.id} to={`/projects/${project.id}`} className="break-words text-xs text-muted-foreground hover:underline">{project.name}</Link>)}</div>
|
||||
<p className="truncate text-xs text-muted-foreground">{source.trackingRef === 'HEAD' ? 'Default branch' : source.trackingRef}{source.lastSuccessAt ? ` · Updated ${timeAgo(source.lastSuccessAt)}` : ''}{source.accessScope === 'projects' ? ' · Project access' : ''}{importingProjectSkills && <span role="status"> · Importing skills…</span>}</p>
|
||||
{source.enabled && source.automaticUpdates === false && <p className="text-xs text-muted-foreground">Automatic updates paused</p>}
|
||||
{source.trackingActive === false && <p className="text-xs text-muted-foreground">Repository tracking stopped · installed skills kept</p>}
|
||||
{!source.enabled && <p className="text-xs text-muted-foreground">Disconnected</p>}
|
||||
@@ -130,14 +139,16 @@ export function SkillSources() {
|
||||
<DropdownMenuCheckboxItem checked={source.automaticUpdates ?? true} disabled={!source.enabled || settings.isPending}
|
||||
onCheckedChange={enabled => settings.mutate({ source, enabled })}>Automatic updates</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuItem onSelect={() => navigate(`/skills/sources/${source.id}`)}>Select skills</DropdownMenuItem>
|
||||
{projectGroup && projectGroup.importableWorkspaces.length > 0 && <DropdownMenuItem disabled={adopt.isPending} onSelect={() => adopt.mutate(projectGroup)}>
|
||||
{projectGroup.repository.status === 'error' ? 'Retry project import' : projectGroup.hasPending ? 'Import remaining project skills' : 'Import project skills'}
|
||||
</DropdownMenuItem>}
|
||||
<DropdownMenuItem onSelect={() => setRepairSourceId(source.id)}>Choose connection</DropdownMenuItem>
|
||||
{source.accessScope !== 'projects' && !source.projects?.some(project => project.active) && <DropdownMenuItem onSelect={() => setBranchSourceId(source.id)}>Choose a new branch</DropdownMenuItem>}
|
||||
<DropdownMenuItem variant="destructive" disabled={disconnect.isPending} onSelect={() => { disconnect.reset(); reloadRemoval.reset(); setDisconnectSource(source); }}>{source.enabled ? 'Disconnect source' : 'Remove source'}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{projectGroup && <ProjectSkillRepositoryRow repository={projectGroup.repository} backgroundPending={projectGroup.hasPending} showRepository={false} disabled={adopt.isPending}
|
||||
pending={adopt.isPending && adopt.variables?.key === projectGroup.key} onImport={() => adopt.mutate(projectGroup)} />}
|
||||
{projectGroup?.repository.lastError && <SourceErrorNotice message={projectGroup.repository.lastError} />}
|
||||
{installed.length > 0 && <ul className="ml-8 min-w-0" aria-label={`Installed skills from ${source.fullName}`}>
|
||||
{installed.map(entry => <li key={entry.id}>
|
||||
<Link to={skillRoute(entry.skillId!)} title={entry.path} className="group flex min-w-0 items-center gap-2 rounded-sm py-1 text-sm hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
@@ -151,15 +162,15 @@ export function SkillSources() {
|
||||
{installed.length === 0 && source.entries.length === 0 && source.syncState !== 'refreshing' && !source.lastError && <p className="ml-8 text-sm text-muted-foreground">
|
||||
No skills found yet.{source.enabled && source.automaticUpdates && source.importNewSkills ? ' New skills will be imported automatically.' : ''}
|
||||
</p>}
|
||||
{localError && <p role="alert" className="text-sm text-destructive">{localError}</p>}
|
||||
{localError && <SourceErrorNotice message={localError} />}
|
||||
<SkillImportWarnings warnings={skillImportWarnings(source.entries.filter(entry => entry.selection === 'selected'), source.repositoryPackage)} />
|
||||
{source.lastError && source.lastAttemptAt && <p className="text-xs text-muted-foreground">Last attempt {timeAgo(source.lastAttemptAt)}</p>}
|
||||
{source.lastError && <p role="alert" className="text-sm text-destructive">{source.lastError} <Link to={`/skills/sources/${source.id}`} className="underline">Review source</Link></p>}
|
||||
{source.lastError && <SourceErrorNotice message={source.lastError}
|
||||
detail={source.lastAttemptAt ? `Last attempt ${timeAgo(source.lastAttemptAt)}` : undefined}
|
||||
action={<Link to={`/skills/sources/${source.id}`} className="font-medium text-destructive underline underline-offset-2">Review source</Link>} />}
|
||||
{source.lastError && (source.repairAction === 'connection' || (!source.repairAction && !source.connectionId)) && <Button variant="outline" size="sm" className="self-start" onClick={() => setRepairSourceId(source.id)}>Choose connection</Button>}
|
||||
{source.lastError && source.repairAction === 'branch' && (source.accessScope === 'projects' || source.projects?.some(project => project.active)
|
||||
? <div className="flex flex-wrap gap-3">{source.projects?.filter(project => project.active).map(project => <Link key={project.id} to={`/projects/${project.id}`} className="text-sm underline">Update {project.name} repository</Link>)}</div>
|
||||
: <Button variant="outline" size="sm" className="self-start" onClick={() => setBranchSourceId(source.id)}>Choose a new branch</Button>)}
|
||||
{source.connectionId && <Link to={`/apps/${source.connectionId}/permissions`} className="text-xs text-muted-foreground hover:text-foreground">Manage GitHub connection</Link>}
|
||||
</section>;
|
||||
})}
|
||||
</div>
|
||||
@@ -180,6 +191,19 @@ export function SkillSources() {
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SourceErrorNotice({ message, detail, action }: { message: string; detail?: string; action?: ReactNode }) {
|
||||
return <div role="alert" className="ml-8 flex min-w-0 items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm">
|
||||
<CircleAlert className="mt-0.5 size-4 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-words text-foreground">{message}</p>
|
||||
{(detail || action) && <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{detail && <span className="text-muted-foreground">{detail}</span>}
|
||||
{action}
|
||||
</div>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DisconnectSourceDialog({ source, pending, error, stale, onReload, onClose, onConfirm, onChoiceChange }: {
|
||||
source: SkillSource; pending: boolean; error?: string; stale: boolean; onReload: () => void; onClose: () => void;
|
||||
onConfirm: (skills: SkillSourceDisconnectRequest['skills']) => void; onChoiceChange: () => void;
|
||||
|
||||
@@ -9,6 +9,8 @@ Open **Agents and reusable capabilities → Skills and Skill Studio → Project
|
||||
navigate to Skills → Sources. Legacy adoption starts before import. Background
|
||||
update introduces skills without a click. Retry the failed refresh, pause updates,
|
||||
inspect the private/shared source and try the mobile and light-theme variants.
|
||||
**Existing source, more projects** reproduces a saved source with additional
|
||||
project imports available in its More menu. The source row shows no project names.
|
||||
Connection repair chooses an existing authorized account without changing skill
|
||||
selections. Also inspect removed skills, invalid upstream content, two private
|
||||
projects and the large-list story.
|
||||
|
||||
@@ -7,12 +7,13 @@ import { projectRepository } from '../../../support/projectRepositorySkills';
|
||||
function Components({ state = 'available' }: { state?: 'available' | 'pending' | 'importing' | 'partial' | 'error' | 'private' | 'paused' | 'refreshing' }) {
|
||||
const [imported, setImported] = useState(false), [automatic, setAutomatic] = useState(state !== 'paused');
|
||||
return <div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
|
||||
<ProjectSkillRepositoryRow repository={{ ...projectRepository, status: imported || ['pending', 'importing'].includes(state) ? 'pending' : state === 'error' ? 'error' : 'available', importState: ['importing', 'partial'].includes(state) ? 'importing' : 'queued',
|
||||
<ProjectSkillRepositoryRow repository={{ ...projectRepository,
|
||||
status: imported || ['pending', 'importing'].includes(state) ? 'pending' : state === 'error' ? 'error' : 'available', importState: ['importing', 'partial'].includes(state) ? 'importing' : 'queued',
|
||||
projectVisibility: state === 'private' ? 'private' : 'open', lastError: state === 'error' ? 'GitHub access is unavailable. Reconnect GitHub and retry.' : null }} backgroundPending={state === 'partial'} onImport={() => setImported(true)} />
|
||||
<SkillSourceSyncControls automaticUpdates={automatic} refreshing={state === 'refreshing'} onAutomaticUpdates={setAutomatic} onRefresh={() => setImported(true)} />
|
||||
</div>;
|
||||
}
|
||||
const meta = { id: 'skills-project-repository-skills-01-components', title: 'Agents and reusable capabilities/Skills and Skill Studio/Project repository skills/01 Components', component: Components, parameters: { paperclip: {"capabilities": ["skills/discovery", "skills/versions"], "status": "current", "implementation": "production", "boundary": "fixtures", "notes": "Production Sources controls with fixture data cover empty, updating, paused, removed, invalid and private-project states. Provider access, persistence and runtime delivery require live acceptance."}, layout: 'fullscreen' } } satisfies Meta<typeof Components>;
|
||||
const meta = { id: 'skills-project-repository-skills-01-components', title: 'Agents and reusable capabilities/Skills and Skill Studio/Project repository skills/01 Components', component: Components, parameters: { paperclip: {"capabilities": ["skills/discovery", "skills/versions"], "status": "current", "implementation": "production", "boundary": "fixtures", "notes": "Production repository row and source controls cover legacy availability, import progress, errors, private access, paused updates and refresh. Existing-source adoption is covered by the journey story. Provider access, persistence and runtime delivery require live acceptance."}, layout: 'fullscreen' } } satisfies Meta<typeof Components>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Available: Story = {};
|
||||
|
||||
@@ -36,13 +36,14 @@ function Journey({ scenario = 'ready' }: { scenario?: Scenario }) {
|
||||
</Route></Routes></PluginLauncherProvider>;
|
||||
}
|
||||
const meta = { id: 'skills-project-repository-skills-02-journeys', title: 'Agents and reusable capabilities/Skills and Skill Studio/Project repository skills/02 Journeys', component: Journey, tags: ['!autodocs'],
|
||||
parameters: { paperclip: {"capabilities": ["skills/discovery", "skills/versions", "projects/repositories", "routines/run-now", "routines/history"], "status": "current", "implementation": "production", "boundary": "fixtures", "notes": "Production app shell and pages exercise project creation/attachment, legacy adoption, refresh/repair and routine revision journeys with in-memory GitHub and execution fixtures. Live authorization and GitHub-to-run delivery are verified separately."}, layout: 'fullscreen', docs: { description: { component: 'Production pages and app shell. Imports, GitHub responses, background refresh and routine executions are in-memory fixtures. Start with Create project or Legacy adoption; all actions remain interactive. Live integration is verified separately.' } } },
|
||||
parameters: { paperclip: {"capabilities": ["skills/discovery", "skills/versions", "projects/repositories", "routines/run-now", "routines/history"], "status": "current", "implementation": "production", "boundary": "fixtures", "notes": "Production app shell and pages exercise project creation/attachment, legacy and existing-source adoption, refresh/repair and routine revision journeys with in-memory GitHub and execution fixtures. Live authorization and GitHub-to-run delivery are verified separately."}, layout: 'fullscreen', docs: { description: { component: 'Production pages and app shell. Imports, GitHub responses, background refresh and routine executions are in-memory fixtures. Start with Create project or Legacy adoption; all actions remain interactive. Live integration is verified separately.' } } },
|
||||
} satisfies Meta<typeof Journey>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const CreateProject: Story = { name: '01 · Create a project', args: { scenario: 'new-project' } };
|
||||
export const LegacyAdoption: Story = { name: '02 · Import from an older project', args: { scenario: 'legacy' } };
|
||||
export const DuplicateProjects: Story = { name: 'Shared repository import across projects', args: { scenario: 'duplicate' } };
|
||||
export const ExistingSourceMoreProjects: Story = { name: 'Existing source, more projects', args: { scenario: 'partial-adoption' } };
|
||||
export const MissingBranch: Story = { name: 'Repair an unavailable branch', args: { scenario: 'missing-branch' } };
|
||||
export const Synced: Story = { name: '03 · Automatically imported skills' };
|
||||
export const AutomaticUpdate: Story = { name: '04 · Background update and a new skill', args: { scenario: 'automatic' } };
|
||||
|
||||
@@ -15,7 +15,7 @@ import { baseRoutine } from '../fixtures/routineWebhooks';
|
||||
import { storybookProjects, storybookIssues } from '../fixtures/paperclipData';
|
||||
import { COMPANY_ID, SOURCE_ID, COMMIT, installFixtures, candidates, sourceFixture } from '../fixtures/githubSkillSources';
|
||||
|
||||
export type Scenario = 'new-project' | 'attach-repository' | 'legacy' | 'duplicate' | 'missing-branch' | 'ready' | 'refreshing' | 'error' | 'empty' | 'private' | 'shared' | 'shared-private' | 'long' | 'many' | 'paused' | 'removed' | 'validation' | 'repair' | 'repair-conflict' | 'automatic' | 'latest';
|
||||
export type Scenario = 'new-project' | 'attach-repository' | 'legacy' | 'duplicate' | 'partial-adoption' | 'missing-branch' | 'ready' | 'refreshing' | 'error' | 'empty' | 'private' | 'shared' | 'shared-private' | 'long' | 'many' | 'paused' | 'removed' | 'validation' | 'repair' | 'repair-conflict' | 'automatic' | 'latest';
|
||||
export const projectRepository: ProjectSkillRepository = {
|
||||
projectId: 'project-skills', projectName: 'Acme platform', projectVisibility: 'open',
|
||||
workspaceId: 'workspace-skills', repositoryUrl: 'https://github.com/acme/team-skills', trackingRef: 'main',
|
||||
@@ -38,9 +38,14 @@ export function installProjectSkillFixtures(scenario: Scenario, client: QueryCli
|
||||
let live = true, automaticUpdates = scenario !== 'paused', failed = ['error', 'repair', 'repair-conflict', 'missing-branch'].includes(scenario);
|
||||
let sourceRevision = sourceFixture().revision, repairConflict = scenario === 'repair-conflict';
|
||||
const privateProject = scenario === 'private';
|
||||
let repository = { ...projectRepository, projectVisibility: privateProject ? 'private' as const : 'open' as const };
|
||||
let repository = { ...projectRepository, projectName: scenario === 'partial-adoption' ? 'Paperclip' : projectRepository.projectName,
|
||||
projectVisibility: privateProject ? 'private' as const : 'open' as const };
|
||||
if (!['new-project', 'legacy', 'duplicate'].includes(scenario)) repository = { ...repository, sourceId: SOURCE_ID, bindingId: 'binding-skills', status: 'ready' };
|
||||
let additionalRepositories: ProjectSkillRepository[] = scenario === 'duplicate' ? [{ ...repository, workspaceId: 'second-workspace', projectId: 'second-project', projectName: 'Sentry' }] : [];
|
||||
let additionalRepositories: ProjectSkillRepository[] = scenario === 'duplicate' ? [{ ...repository, workspaceId: 'second-workspace', projectId: 'second-project', projectName: 'Sentry' }]
|
||||
: scenario === 'partial-adoption' ? [
|
||||
{ ...repository, workspaceId: 'cloud-workspace', projectId: 'paperclip-cloud', projectName: 'Paperclip Cloud', sourceId: null, bindingId: null, status: 'available' },
|
||||
{ ...repository, workspaceId: 'sentry-workspace', projectId: 'sentry', projectName: 'Sentry', sourceId: null, bindingId: null, status: 'available' },
|
||||
] : [];
|
||||
let project = { ...storybookProjects[0]!, id: repository.projectId, urlKey: repository.projectId, name: repository.projectName, companyId: COMPANY_ID,
|
||||
workspaces: storybookProjects[0]!.workspaces.slice(0, 1).map(workspace => ({ ...workspace, id: repository.workspaceId, projectId: repository.projectId, repoUrl: repository.repositoryUrl, repoRef: 'main', defaultRef: 'main', name: 'acme/team-skills', metadata: { ...workspace.metadata, githubRepositoryId: '123456' } })) };
|
||||
const workspaceTemplate = project.workspaces[0]!;
|
||||
@@ -89,6 +94,8 @@ export function installProjectSkillFixtures(scenario: Scenario, client: QueryCli
|
||||
repairAction: !failed ? null : scenario === 'missing-branch' ? 'branch' : scenario.startsWith('repair') ? 'connection' : 'retry',
|
||||
projects: scenario === 'missing-branch' ? [] : ['shared', 'shared-private'].includes(scenario) ? [{ id: project.id, name: 'Private research', visibility: 'private', active: true },
|
||||
{ id: 'project-open', name: 'Engineering', visibility: scenario === 'shared' ? 'open' : 'private', active: true }]
|
||||
: scenario === 'partial-adoption' ? [repository, ...additionalRepositories].filter(row => row.sourceId === source.id && row.status === 'ready')
|
||||
.map(row => ({ id: row.projectId, name: row.projectName, visibility: row.projectVisibility, active: true }))
|
||||
: [{ id: project.id, name: project.name, visibility: repository.projectVisibility, active: true }],
|
||||
entries: scenario === 'empty' ? [] : scenario === 'many' ? Array.from({ length: 80 }, (_, index) => ({
|
||||
...source.entries[0]!, id: 'entry-' + index, name: 'Project workflow ' + (index + 1), skillId: 'skill-' + index,
|
||||
|
||||
Reference in new issue
Block a user