diff --git a/.agents/skills/boxel-development/references/dev-core-patterns.md b/.agents/skills/boxel-development/references/dev-core-patterns.md index 8afd7634a9c..04663e15587 100644 --- a/.agents/skills/boxel-development/references/dev-core-patterns.md +++ b/.agents/skills/boxel-development/references/dev-core-patterns.md @@ -1,10 +1,14 @@ -**Card with computed title:** +**Card with computed cardTitle:** + +A card's display name comes from `cardTitle` (a computed pass-through from +`cardInfo.name`). To derive it from another field, override `cardTitle` — do +NOT override the base `title` field; the host reads `cardTitle`, not `title`. ```gts export class BlogPost extends CardDef { @field headline = contains(StringField); - @field title = contains(StringField, { + @field cardTitle = contains(StringField, { computeVia: function (this: BlogPost) { return this.headline ?? 'Untitled Post'; }, @@ -60,7 +64,7 @@ export class BlogPost extends CardDef { @field tags = containsMany(TagField); @field relatedPosts = linksToMany(() => BlogPost); - @field title = contains(StringField, { + @field cardTitle = contains(StringField, { computeVia: function (this: BlogPost) { try { const baseTitle = this.headline ?? 'Untitled Post'; @@ -68,7 +72,7 @@ export class BlogPost extends CardDef { if (baseTitle.length <= maxLength) return baseTitle; return baseTitle.substring(0, maxLength - 3) + '...'; } catch (e) { - console.error('BlogPost: Error computing title', e); + console.error('BlogPost: Error computing cardTitle', e); return 'Untitled Post'; } }, @@ -137,9 +141,9 @@ export class AddressField extends FieldDef { ```gts // ❌ DANGEROUS: Self-reference causes infinite recursion -@field title = contains(StringField, { +@field cardTitle = contains(StringField, { computeVia: function(this: BlogPost) { - return this.title || 'Untitled'; // STACK OVERFLOW! + return this.cardTitle || 'Untitled'; // STACK OVERFLOW! } }); @@ -170,9 +174,9 @@ static isolated = class Isolated extends Component { // ³⁰ I // ³¹ CRITICAL: Do ALL computation in functions, never in templates get safeTitle() { try { - return this.args?.model?.title ?? 'Untitled Post'; + return this.args?.model?.cardTitle ?? 'Untitled Post'; } catch (e) { - console.error('BlogPost: Error accessing title', e); + console.error('BlogPost: Error accessing cardTitle', e); return 'Untitled Post'; } } diff --git a/.agents/skills/boxel-development/references/dev-enumerations.md b/.agents/skills/boxel-development/references/dev-enumerations.md index 21255d69340..c181d4902d1 100644 --- a/.agents/skills/boxel-development/references/dev-enumerations.md +++ b/.agents/skills/boxel-development/references/dev-enumerations.md @@ -218,7 +218,7 @@ export class Task extends CardDef { @field taskName = contains(StringField); @field priority = contains(PriorityField); - @field title = contains(StringField, { + @field cardTitle = contains(StringField, { computeVia: function (this: Task) { return this.taskName ?? 'Untitled Task'; }, diff --git a/packages/software-factory/realm/eval-result.gts b/packages/software-factory/realm/eval-result.gts index 61285135fde..209e432533d 100644 --- a/packages/software-factory/realm/eval-result.gts +++ b/packages/software-factory/realm/eval-result.gts @@ -1,26 +1,25 @@ import { - CardDef, FieldDef, Component, field, contains, containsMany, - linksTo, } from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; import NumberField from 'https://cardstack.com/base/number'; -import DateTimeField from 'https://cardstack.com/base/datetime'; -import enumField from 'https://cardstack.com/base/enum'; -import { Project, Issue } from './darkfactory.gts'; - -export const EvalResultStatusField = enumField(StringField, { - options: [ - { value: 'running', label: 'Running' }, - { value: 'passed', label: 'Passed' }, - { value: 'failed', label: 'Failed' }, - { value: 'error', label: 'Error' }, - ], -}); +import { ValidationResult } from './validation-result.gts'; +import { + ResultFittedCard, + resultDisplayStatus, + resultRunTitle, + type ResultMetaItem, +} from './result-fitted-card.gts'; +import { ResultIsolatedCard } from './result-isolated-card.gts'; +import { ResultDetailGroup } from './result-detail-group.gts'; + +import Code from '@cardstack/boxel-icons/code'; +import CircleCheck from '@cardstack/boxel-icons/circle-check'; +import CircleX from '@cardstack/boxel-icons/circle-x'; export class EvalModuleResult extends FieldDef { static displayName = 'Eval Module Result'; @@ -35,64 +34,23 @@ export class EvalModuleResult extends FieldDef { static embedded = class Embedded extends Component { }; } -export class EvalResult extends CardDef { +export class EvalResult extends ValidationResult { static displayName = 'Eval Result'; + static icon = Code; - @field sequenceNumber = contains(NumberField); - @field runAt = contains(DateTimeField); - @field completedAt = contains(DateTimeField); - @field project = linksTo(() => Project); - @field issue = linksTo(() => Issue); - @field status = contains(EvalResultStatusField); - @field durationMs = contains(NumberField); @field moduleResults = containsMany(EvalModuleResult); - @field errorMessage = contains(StringField); @field modulesChecked = contains(NumberField, { computeVia: function (this: EvalResult) { @@ -106,13 +64,9 @@ export class EvalResult extends CardDef { }, }); - @field title = contains(StringField, { - computeVia: function (this: EvalResult) { - let seq = this.sequenceNumber ?? '?'; - let status = this.status ?? 'unknown'; - return `EvalResult #${seq} \u2014 ${status}`; - }, - }); + get resultLabel() { + return 'EvalResult'; + } get modulesPassed() { return (this.modulesChecked ?? 0) - (this.modulesWithErrors ?? 0); @@ -120,102 +74,51 @@ export class EvalResult extends CardDef { static fitted = class Fitted extends Component { get displayStatus() { - if ( - (this.args.model.modulesChecked ?? 0) === 0 && - this.args.model.status === 'passed' - ) { - return 'empty'; + return resultDisplayStatus( + this.args.model.modulesChecked, + this.args.model.status, + ); + } + + get titleText() { + return this.args.model.issue?.summary ?? 'Eval Run'; + } + + get metaItems(): ResultMetaItem[] { + let m = this.args.model; + let items: ResultMetaItem[] = []; + if (m.modulesChecked) { + items.push({ + icon: CircleCheck, + text: `${m.modulesPassed}/${m.modulesChecked} passed`, + tone: 'clean', + }); } - return this.args.model.status; + if (m.modulesWithErrors) { + items.push({ + icon: CircleX, + text: `${m.modulesWithErrors} error(s)`, + tone: 'error', + }); + } + return items; } }; @@ -223,192 +126,45 @@ export class EvalResult extends CardDef { static isolated = class Isolated extends Component { get displayStatus() { - if ( - (this.args.model.modulesChecked ?? 0) === 0 && - this.args.model.status === 'passed' - ) { - return 'empty'; - } - return this.args.model.status; + return resultDisplayStatus( + this.args.model.modulesChecked, + this.args.model.status, + ); + } + + get titleText() { + return resultRunTitle('Eval', this.args.model.sequenceNumber); } }; + + static edit = this.isolated; } diff --git a/packages/software-factory/realm/instantiate-result.gts b/packages/software-factory/realm/instantiate-result.gts index ac128f8677c..224891b8507 100644 --- a/packages/software-factory/realm/instantiate-result.gts +++ b/packages/software-factory/realm/instantiate-result.gts @@ -1,29 +1,28 @@ import { - CardDef, FieldDef, Component, field, contains, containsMany, - linksTo, realmURL, } from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; import NumberField from 'https://cardstack.com/base/number'; -import DateTimeField from 'https://cardstack.com/base/datetime'; import CodeRefField from 'https://cardstack.com/base/code-ref'; -import enumField from 'https://cardstack.com/base/enum'; import { RealmPaths } from '@cardstack/runtime-common'; -import { Project, Issue } from './darkfactory.gts'; - -export const InstantiateResultStatusField = enumField(StringField, { - options: [ - { value: 'running', label: 'Running' }, - { value: 'passed', label: 'Passed' }, - { value: 'failed', label: 'Failed' }, - { value: 'error', label: 'Error' }, - ], -}); +import { ValidationResult } from './validation-result.gts'; +import { + ResultFittedCard, + resultDisplayStatus, + resultRunTitle, + type ResultMetaItem, +} from './result-fitted-card.gts'; +import { ResultIsolatedCard } from './result-isolated-card.gts'; +import { ResultDetailGroup } from './result-detail-group.gts'; + +import Box from '@cardstack/boxel-icons/box'; +import CircleCheck from '@cardstack/boxel-icons/circle-check'; +import CircleX from '@cardstack/boxel-icons/circle-x'; export class InstantiateCardEntry extends FieldDef { static displayName = 'Instantiate Card Entry'; @@ -58,64 +57,23 @@ export class InstantiateCardEntry extends FieldDef { typeof InstantiateCardEntry > { }; } -export class InstantiateResult extends CardDef { +export class InstantiateResult extends ValidationResult { static displayName = 'Instantiate Result'; + static icon = Box; - @field sequenceNumber = contains(NumberField); - @field runAt = contains(DateTimeField); - @field completedAt = contains(DateTimeField); - @field project = linksTo(() => Project); - @field issue = linksTo(() => Issue); - @field status = contains(InstantiateResultStatusField); - @field durationMs = contains(NumberField); @field cardResults = containsMany(InstantiateCardEntry); - @field errorMessage = contains(StringField); @field cardsChecked = contains(NumberField, { computeVia: function (this: InstantiateResult) { @@ -129,13 +87,9 @@ export class InstantiateResult extends CardDef { }, }); - @field title = contains(StringField, { - computeVia: function (this: InstantiateResult) { - let seq = this.sequenceNumber ?? '?'; - let status = this.status ?? 'unknown'; - return `InstantiateResult #${seq} \u2014 ${status}`; - }, - }); + get resultLabel() { + return 'InstantiateResult'; + } get cardsPassed() { return (this.cardsChecked ?? 0) - (this.cardsWithErrors ?? 0); @@ -143,102 +97,51 @@ export class InstantiateResult extends CardDef { static fitted = class Fitted extends Component { get displayStatus() { - if ( - (this.args.model.cardsChecked ?? 0) === 0 && - this.args.model.status === 'passed' - ) { - return 'empty'; + return resultDisplayStatus( + this.args.model.cardsChecked, + this.args.model.status, + ); + } + + get titleText() { + return this.args.model.issue?.summary ?? 'Instantiate Run'; + } + + get metaItems(): ResultMetaItem[] { + let m = this.args.model; + let items: ResultMetaItem[] = []; + if (m.cardsChecked) { + items.push({ + icon: CircleCheck, + text: `${m.cardsPassed}/${m.cardsChecked} passed`, + tone: 'clean', + }); } - return this.args.model.status; + if (m.cardsWithErrors) { + items.push({ + icon: CircleX, + text: `${m.cardsWithErrors} error(s)`, + tone: 'error', + }); + } + return items; } }; @@ -246,193 +149,45 @@ export class InstantiateResult extends CardDef { static isolated = class Isolated extends Component { get displayStatus() { - if ( - (this.args.model.cardsChecked ?? 0) === 0 && - this.args.model.status === 'passed' - ) { - return 'empty'; - } - return this.args.model.status; + return resultDisplayStatus( + this.args.model.cardsChecked, + this.args.model.status, + ); + } + + get titleText() { + return resultRunTitle('Instantiate', this.args.model.sequenceNumber); } }; + + static edit = this.isolated; } diff --git a/packages/software-factory/realm/issue-tracker.gts b/packages/software-factory/realm/issue-tracker.gts index 0c3af83d392..98fe1ab58f2 100644 --- a/packages/software-factory/realm/issue-tracker.gts +++ b/packages/software-factory/realm/issue-tracker.gts @@ -1,4 +1,4 @@ -import { tracked } from '@glimmer/tracking'; +import { cached, tracked } from '@glimmer/tracking'; import { get } from '@ember/helper'; import { on } from '@ember/modifier'; import type Owner from '@ember/owner'; @@ -2678,6 +2678,7 @@ class IssueTrackerIsolated extends Component { this.uncategorizedCollapsed = false; }; + @cached get columns(): KanbanColumnConfig[] { let options = this.activeGroupBy === 'priority' @@ -2748,10 +2749,12 @@ class IssueTrackerIsolated extends Component { ); } + @cached get configurableColumns(): KanbanColumnConfig[] { return this.columns.filter((c) => c.key !== 'uncategorized'); } + @cached get firstColumn(): KanbanColumnConfig | undefined { return [...this.columns].sort((a, b) => a.sortOrder - b.sortOrder)[0]; } @@ -2760,21 +2763,26 @@ class IssueTrackerIsolated extends Component { return this.args.model?.cards?.length ?? 0; } - get columnCardCounts(): number[] { - return this.columns.map( - (col: KanbanColumnConfig) => - this.placements.filter((p) => p.columnId === col.key).length, - ); - } - + @cached get columnCardCountsByKey(): Record { let result: Record = {}; - this.columns.forEach((col, i) => { - result[col.key] = this.columnCardCounts[i] ?? 0; - }); + for (let col of this.columns) { + result[col.key] = 0; + } + for (let p of this.placements) { + if (p.columnId in result) { + result[p.columnId] += 1; + } + } return result; } + @cached + get columnCardCounts(): number[] { + let counts = this.columnCardCountsByKey; + return this.columns.map((col: KanbanColumnConfig) => counts[col.key] ?? 0); + } + get hideEmpty(): boolean { return this.args.model.hideEmptyColumns ?? false; } @@ -2989,29 +2997,34 @@ class IssueTrackerIsolated extends Component { } }; + @cached get placements(): KanbanPlacement[] { let stored = this.args.model?.placements; let cards = this.args.model?.cards ?? []; let fieldName = this.groupByFieldName; + let columnKeys = new Set(this.columns.map((c) => c.key)); + let firstColumnKey = this.firstColumn?.key ?? ''; let resolveColumn = (fieldValue: string | null | undefined): string => { return ( - (fieldValue - ? this.columns.find((c) => c.key === fieldValue)?.key - : undefined) ?? - (this.activeGroupBy === 'status' - ? this.columns.find((c) => c.key === 'backlog')?.key + (fieldValue && columnKeys.has(fieldValue) ? fieldValue : undefined) ?? + (this.activeGroupBy === 'status' && columnKeys.has('backlog') + ? 'backlog' : undefined) ?? - this.columns.find((c) => c.key === 'uncategorized')?.key ?? - this.firstColumn?.key ?? - '' + (columnKeys.has('uncategorized') ? 'uncategorized' : undefined) ?? + firstColumnKey ); }; if (stored?.length) { + let cardIdxById = new Map(); + cards.forEach((c, idx) => { + let id = (c as any).id; + if (id != null && !cardIdxById.has(id)) cardIdxById.set(id, idx); + }); let placedCardIds = new Set(stored.map((p) => p.itemId)); let resolved = stored .map((p) => { - let cardIdx = cards.findIndex((c) => (c as any).id === p.itemId); + let cardIdx = cardIdxById.get(p.itemId) ?? -1; if (cardIdx === -1) return null; let card = cards[cardIdx] as any; let colKey = resolveColumn(card[fieldName] ?? p.columnKey); diff --git a/packages/software-factory/realm/lint-result.gts b/packages/software-factory/realm/lint-result.gts index ac8903ead49..3dfb5c84a39 100644 --- a/packages/software-factory/realm/lint-result.gts +++ b/packages/software-factory/realm/lint-result.gts @@ -1,26 +1,29 @@ import { - CardDef, FieldDef, Component, field, contains, containsMany, - linksTo, } from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; import NumberField from 'https://cardstack.com/base/number'; -import DateTimeField from 'https://cardstack.com/base/datetime'; import enumField from 'https://cardstack.com/base/enum'; -import { Project, Issue } from './darkfactory.gts'; +import { eq } from '@cardstack/boxel-ui/helpers'; +import { ValidationResult } from './validation-result.gts'; +import { + ResultFittedCard, + resultDisplayStatus, + resultRunTitle, + type ResultMetaItem, +} from './result-fitted-card.gts'; +import { ResultIsolatedCard } from './result-isolated-card.gts'; +import { ResultDetailGroup } from './result-detail-group.gts'; +import { ResultDetailRow } from './result-detail-row.gts'; -export const LintResultStatusField = enumField(StringField, { - options: [ - { value: 'running', label: 'Running' }, - { value: 'passed', label: 'Passed' }, - { value: 'failed', label: 'Failed' }, - { value: 'error', label: 'Error' }, - ], -}); +import ListChecks from '@cardstack/boxel-icons/list-checks'; +import CircleCheck from '@cardstack/boxel-icons/circle-check'; +import CircleX from '@cardstack/boxel-icons/circle-x'; +import CircleAlert from '@cardstack/boxel-icons/circle-alert'; export const LintViolationSeverityField = enumField(StringField, { options: [ @@ -39,64 +42,15 @@ export class LintViolation extends FieldDef { @field message = contains(StringField); @field severity = contains(LintViolationSeverityField); - get severityIcon() { - switch (this.severity) { - case 'error': - return '\u2717'; - case 'warning': - return '\u26A0'; - default: - return '?'; - } - } - static embedded = class Embedded extends Component { }; } @@ -130,76 +84,35 @@ export class LintFileResult extends FieldDef { } static embedded = class Embedded extends Component { + get statusLabel() { + let m = this.args.model; + if (m.passed) return 'clean'; + let label = `${m.errorCount} error(s)`; + if (m.warningCount) label += `, ${m.warningCount} warning(s)`; + return label; + } + }; } -export class LintResult extends CardDef { +export class LintResult extends ValidationResult { static displayName = 'Lint Result'; + static icon = ListChecks; - @field sequenceNumber = contains(NumberField); - @field runAt = contains(DateTimeField); - @field completedAt = contains(DateTimeField); - @field project = linksTo(() => Project); - @field issue = linksTo(() => Issue); - @field status = contains(LintResultStatusField); - @field durationMs = contains(NumberField); @field fileResults = containsMany(LintFileResult); - @field errorMessage = contains(StringField); @field totalErrors = contains(NumberField, { computeVia: function (this: LintResult) { @@ -225,13 +138,9 @@ export class LintResult extends CardDef { }, }); - @field title = contains(StringField, { - computeVia: function (this: LintResult) { - let seq = this.sequenceNumber ?? '?'; - let status = this.status ?? 'unknown'; - return `LintResult #${seq} \u2014 ${status}`; - }, - }); + get resultLabel() { + return 'LintResult'; + } get filesWithErrors() { return (this.fileResults ?? []).filter((fr) => !fr.passed).length; @@ -243,102 +152,58 @@ export class LintResult extends CardDef { static fitted = class Fitted extends Component { get displayStatus() { - if ( - (this.args.model.filesChecked ?? 0) === 0 && - this.args.model.status === 'passed' - ) { - return 'empty'; + return resultDisplayStatus( + this.args.model.filesChecked, + this.args.model.status, + ); + } + + get titleText() { + return this.args.model.issue?.summary ?? 'Lint Run'; + } + + get metaItems(): ResultMetaItem[] { + let m = this.args.model; + let items: ResultMetaItem[] = []; + if (m.filesChecked) { + items.push({ + icon: CircleCheck, + text: `${m.filesClean}/${m.filesChecked} clean`, + tone: 'clean', + }); + } + if (m.totalErrors) { + items.push({ + icon: CircleX, + text: `${m.totalErrors} error(s)`, + tone: 'error', + }); } - return this.args.model.status; + if (m.totalWarnings) { + items.push({ + icon: CircleAlert, + text: `${m.totalWarnings} warning(s)`, + tone: 'warning', + }); + } + return items; } }; @@ -346,245 +211,47 @@ export class LintResult extends CardDef { static isolated = class Isolated extends Component { get displayStatus() { - if ( - (this.args.model.filesChecked ?? 0) === 0 && - this.args.model.status === 'passed' - ) { - return 'empty'; - } - return this.args.model.status; + return resultDisplayStatus( + this.args.model.filesChecked, + this.args.model.status, + ); + } + + get titleText() { + return resultRunTitle('Lint', this.args.model.sequenceNumber); } }; + + static edit = this.isolated; } diff --git a/packages/software-factory/realm/overview.gts b/packages/software-factory/realm/overview.gts index 472a2e681d0..a644f4a219b 100644 --- a/packages/software-factory/realm/overview.gts +++ b/packages/software-factory/realm/overview.gts @@ -30,6 +30,7 @@ import { issuePriorityOptions, issueStatusOptions, issueTypeOptions, + type Option, } from './kanban-config.gts'; import { EmptyState } from './empty-state.gts'; import { StatusPill } from './status-pill.gts'; @@ -39,14 +40,35 @@ import type { RealmDashboard } from './realm-dashboard.gts'; const importMetaUrl: string = import.meta.url; // The five validation-result card types the factory writes under -// `Validations/` after every agent turn. A `type` filter matches each -// card and its subclasses, so the tab surfaces the whole pipeline. -const VALIDATION_TYPES = [ - codeRef(importMetaUrl, './parse-result', 'ParseResult'), - codeRef(importMetaUrl, './lint-result', 'LintResult'), - codeRef(importMetaUrl, './eval-result', 'EvalResult'), - codeRef(importMetaUrl, './instantiate-result', 'InstantiateResult'), - codeRef(importMetaUrl, './test-results', 'TestRun'), +// `Validations/` after every agent turn, in pipeline order. A `type` filter +// matches each card and its subclasses, so the tab surfaces the whole +// pipeline; the Validation Runs widget renders one group per type. +const VALIDATION_TYPE_GROUPS = [ + { + key: 'parse', + label: 'Parse', + ref: codeRef(importMetaUrl, './parse-result', 'ParseResult'), + }, + { + key: 'lint', + label: 'Lint', + ref: codeRef(importMetaUrl, './lint-result', 'LintResult'), + }, + { + key: 'eval', + label: 'Eval', + ref: codeRef(importMetaUrl, './eval-result', 'EvalResult'), + }, + { + key: 'instantiate', + label: 'Instantiate', + ref: codeRef(importMetaUrl, './instantiate-result', 'InstantiateResult'), + }, + { + key: 'test', + label: 'Tests', + ref: codeRef(importMetaUrl, './test-results', 'TestRun'), + }, ]; const KNOWLEDGE_TYPE = codeRef( @@ -62,12 +84,6 @@ interface FunnelRow { count: number; } -interface Option { - value: string; - label: string; - color?: string; -} - type SetupStatus = 'done' | 'active' | 'upcoming'; interface SetupStep { @@ -176,20 +192,23 @@ export class Overview extends GlimmerComponent { return url ? [url.href] : []; } - // Validation results link *to* an issue, so to group them we run one - // query per issue. Each of the five result types has its own `issue` - // field, so the `issue.id` constraint is scoped per type via `on`. - validationQueryForIssue = ( - issueId: string | undefined, + get validationTypeGroups() { + return VALIDATION_TYPE_GROUPS; + } + + // One query per validation type, newest run first. Grouping by type keeps + // the whole pipeline (parse → lint → eval → instantiate → test) legible at a + // glance; each result card already names its issue, so issue context isn't + // lost by dropping the per-issue grouping. Sort by `runAt` (a global + // timestamp): `sequenceNumber` restarts at 1 per issue slug, so it would rank + // an older issue's run #5 ahead of a newer issue's run #1. + validationQueryForType = ( + ref: (typeof VALIDATION_TYPE_GROUPS)[number]['ref'], ): SearchEntryWireQuery => { return { ...searchEntryWireQueryFromQuery({ - filter: { - any: VALIDATION_TYPES.map((ref) => ({ - on: ref, - eq: { 'issue.id': issueId ?? '' }, - })), - }, + filter: { type: ref }, + sort: [{ by: 'runAt', on: ref, direction: 'desc' }], }), realms: this.validationRealms, }; @@ -328,12 +347,24 @@ export class Overview extends GlimmerComponent { > {{#if (eq step.status 'done')}} -