fix(FN-680): restore TaskStore project resolution and fresh schema writes

- fix TaskStore.getOrCreateForProject to resolve projects through BackwardCompat and support legacy .fusion/.kb detection
- add regression tests for exact-name project lookup, legacy cwd fallback, and fresh-schema task create/update persistence
- sync the tasks schema definition with missionId support so fresh databases match runtime migrations
- add project overview and selector styles for the multi-project dashboard UI
This commit is contained in:
gsxdsm
2026-04-01 13:48:01 -07:00
parent 27d2f25b42
commit 614c710de8
7 changed files with 724 additions and 108 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Fix project resolution and fresh-schema task persistence regressions.

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { TaskStore } from "../store.js"; import { TaskStore } from "../store.js";
@@ -10,10 +11,13 @@ function createTempDir(): string {
return mkdtempSync(join(tmpdir(), "kb-compat-test-")); return mkdtempSync(join(tmpdir(), "kb-compat-test-"));
} }
// Helper to create a fake kb project structure // Helper to create a fake fusion project structure for the current store implementation
function createFakeKbProject(dir: string): void { function createFakeFusionProject(dir: string): void {
mkdirSync(join(dir, ".kb"), { recursive: true }); const fusionDir = join(dir, ".fusion");
writeFileSync(join(dir, ".kb", "kb.db"), ""); mkdirSync(fusionDir, { recursive: true });
const db = new DatabaseSync(join(fusionDir, "kb.db"));
db.exec("CREATE TABLE IF NOT EXISTS sanity (id INTEGER PRIMARY KEY)");
db.close();
} }
describe("TaskStore Backward Compatibility", () => { describe("TaskStore Backward Compatibility", () => {
@@ -58,7 +62,7 @@ describe("TaskStore Backward Compatibility", () => {
expect(settings).toBeDefined(); expect(settings).toBeDefined();
}); });
it("should find project by name when ID not found", async () => { it("should fall back to exact project name lookup when ID lookup misses", async () => {
const projectDir = join(tempDir, "my-project"); const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true }); mkdirSync(projectDir, { recursive: true });
@@ -115,13 +119,24 @@ describe("TaskStore Backward Compatibility", () => {
}); });
it("should fall back to legacy mode when no projects registered", async () => { it("should fall back to process.cwd() legacy mode against the current .fusion path", async () => {
// No projects registered in central core const projectDir = join(tempDir, "legacy-project");
process.chdir(tempDir); mkdirSync(projectDir, { recursive: true });
createFakeFusionProject(projectDir);
process.chdir(projectDir);
const centralDb = join(tempDir, "kb-central.db");
await centralCore.close();
rmSync(centralDb, { force: true });
centralCore = new CentralCore(tempDir);
const store = await TaskStore.getOrCreateForProject(undefined, centralCore); const store = await TaskStore.getOrCreateForProject(undefined, centralCore);
expect(store).toBeInstanceOf(TaskStore); expect(store).toBeInstanceOf(TaskStore);
const task = await store.createTask({ description: "legacy task" });
expect(task.id).toBe("FN-001");
expect(existsSync(join(projectDir, ".fusion", "kb.db"))).toBe(true);
expect(existsSync(join(projectDir, ".fusion", "tasks", task.id, "task.json"))).toBe(true);
}); });
it("should throw when project ID not found", async () => { it("should throw when project ID not found", async () => {
@@ -130,6 +145,20 @@ describe("TaskStore Backward Compatibility", () => {
).rejects.toThrow('Project "non-existent-project" not found'); ).rejects.toThrow('Project "non-existent-project" not found');
}); });
it("should find project by exact registered name", async () => {
const projectDir = join(tempDir, "Casey");
mkdirSync(projectDir, { recursive: true });
await centralCore.registerProject({
name: "Casey",
path: projectDir,
isolationMode: "in-process",
});
const store = await TaskStore.getOrCreateForProject("Casey", centralCore);
expect(store).toBeInstanceOf(TaskStore);
});
it("should auto-initialize central core if not provided", async () => { it("should auto-initialize central core if not provided", async () => {
const projectDir = join(tempDir, "my-project"); const projectDir = join(tempDir, "my-project");
mkdirSync(projectDir, { recursive: true }); mkdirSync(projectDir, { recursive: true });

View File

@@ -161,6 +161,7 @@ CREATE TABLE IF NOT EXISTS tasks (
breakIntoSubtasks INTEGER DEFAULT 0, breakIntoSubtasks INTEGER DEFAULT 0,
enabledWorkflowSteps TEXT DEFAULT '[]', enabledWorkflowSteps TEXT DEFAULT '[]',
modifiedFiles TEXT DEFAULT '[]', modifiedFiles TEXT DEFAULT '[]',
missionId TEXT,
sliceId TEXT sliceId TEXT
); );

View File

@@ -541,8 +541,8 @@ export class BackwardCompat {
const projects = await this.central.listProjects(); const projects = await this.central.listProjects();
if (projects.length === 0) { if (projects.length === 0) {
// No projects registered - check if cwd has a .kb/ directory // No projects registered - check if cwd has a current .fusion project or legacy .kb project
if (this.hasKbProject(cwd)) { if (this.hasProjectData(cwd)) {
// Auto-migrate this project // Auto-migrate this project
const coordinator = new MigrationCoordinator(this.central); const coordinator = new MigrationCoordinator(this.central);
const result = await coordinator.registerSingleProject(cwd); const result = await coordinator.registerSingleProject(cwd);
@@ -612,13 +612,17 @@ export class BackwardCompat {
} }
/** /**
* Check if a directory contains a kb project. * Check if a directory contains a current .fusion project or legacy .kb project.
*/ */
private hasKbProject(dir: string): boolean { private hasProjectData(dir: string): boolean {
const kbDir = join(dir, ".kb"); return this.hasProjectDb(dir, ".fusion") || this.hasProjectDb(dir, ".kb");
const dbPath = join(kbDir, "kb.db"); }
if (!existsSync(kbDir)) return false; private hasProjectDb(dir: string, folderName: ".fusion" | ".kb"): boolean {
const projectDir = join(dir, folderName);
const dbPath = join(projectDir, "kb.db");
if (!existsSync(projectDir)) return false;
if (!existsSync(dbPath)) return false; if (!existsSync(dbPath)) return false;
try { try {

View File

@@ -823,6 +823,82 @@ describe("TaskStore", () => {
}); });
}); });
describe("upsertTask regression coverage", () => {
it("creates tasks successfully on a fresh database schema", async () => {
const freshRoot = makeTmpDir();
const freshGlobal = makeTmpDir();
const freshStore = new TaskStore(freshRoot, freshGlobal);
await freshStore.init();
const task = await freshStore.createTask({ description: "fresh schema task" });
expect(task.id).toBe("FN-001");
expect(await freshStore.getTask(task.id)).toBeDefined();
freshStore.stopWatching();
await rm(freshRoot, { recursive: true, force: true });
await rm(freshGlobal, { recursive: true, force: true });
});
it("persists createTask with nullable, array, and optional scalar fields", async () => {
const created = await store.createTask({
title: "Persist me",
description: "Create path coverage",
column: "todo",
dependencies: ["FN-999"],
enabledWorkflowSteps: ["WS-001"],
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
modelPresetId: "normal",
});
const persisted = await store.getTask(created.id);
expect(persisted.title).toBe("Persist me");
expect(persisted.column).toBe("todo");
expect(persisted.dependencies).toEqual(["FN-999"]);
expect(persisted.enabledWorkflowSteps).toEqual(["WS-001"]);
expect(persisted.modelProvider).toBe("anthropic");
expect(persisted.validatorModelProvider).toBe("openai");
expect(persisted.modelPresetId).toBe("normal");
});
it("persists updateTask changes across scalar, array, and nullable JSON-backed fields", async () => {
const task = await store.createTask({ description: "Update path coverage" });
await store.updateTask(task.id, {
title: "Updated title",
dependencies: ["FN-002", "FN-003"],
blockedBy: "FN-002",
status: "failed",
error: "boom",
summary: "summary",
workflowStepResults: [
{
workflowStepId: "WS-001",
workflowStepName: "QA",
status: "passed",
startedAt: "2026-04-01T00:00:00.000Z",
completedAt: "2026-04-01T00:01:00.000Z",
output: "ok",
},
],
modifiedFiles: ["packages/core/src/store.ts"],
});
const persisted = await store.getTask(task.id);
expect(persisted.title).toBe("Updated title");
expect(persisted.dependencies).toEqual(["FN-002", "FN-003"]);
expect(persisted.blockedBy).toBe("FN-002");
expect(persisted.status).toBe("failed");
expect(persisted.error).toBe("boom");
expect(persisted.summary).toBe("summary");
expect(persisted.workflowStepResults).toHaveLength(1);
expect(persisted.workflowStepResults?.[0].workflowStepId).toBe("WS-001");
expect(persisted.modifiedFiles).toEqual(["packages/core/src/store.ts"]);
});
});
describe("directory recreation for file-backed blobs", () => { describe("directory recreation for file-backed blobs", () => {
it("pauseTask recreates missing task directory before writing task.json", async () => { it("pauseTask recreates missing task directory before writing task.json", async () => {
const task = await createTestTask(); const task = await createTestTask();

View File

@@ -9,6 +9,8 @@ import { GlobalSettingsStore } from "./global-settings.js";
import { Database, toJson, toJsonNullable, fromJson } from "./db.js"; import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js"; import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js";
import { MissionStore } from "./mission-store.js"; import { MissionStore } from "./mission-store.js";
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
import { CentralCore } from "./central-core.js";
export interface TaskStoreEvents { export interface TaskStoreEvents {
"task:created": [task: Task]; "task:created": [task: Task];
@@ -21,6 +23,36 @@ export interface TaskStoreEvents {
} }
export class TaskStore extends EventEmitter<TaskStoreEvents> { export class TaskStore extends EventEmitter<TaskStoreEvents> {
static async getOrCreateForProject(projectId?: string, centralCore?: CentralCore): Promise<TaskStore> {
const central = centralCore ?? new CentralCore();
let initializedHere = false;
if (!centralCore) {
await central.init();
initializedHere = true;
}
try {
const compat = new BackwardCompat(central);
const context = await compat.resolveProjectContext(process.cwd(), projectId);
const store = new TaskStore(context.workingDirectory);
await store.init();
return store;
} catch (error) {
if (error instanceof ProjectRequiredError) {
if (projectId) {
throw new Error(`Project "${projectId}" not found`);
}
throw new Error(error.message);
}
throw error;
} finally {
if (initializedHere) {
await central.close();
}
}
}
/** /**
* Hybrid storage note: task metadata lives in SQLite, while blob files remain on disk. * Hybrid storage note: task metadata lives in SQLite, while blob files remain on disk.
* Any write to `.kb/tasks/{id}` must recreate the directory on demand, and any read from * Any write to `.kb/tasks/{id}` must recreate the directory on demand, and any read from
@@ -2062,8 +2094,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.updatedAt = new Date().toISOString(); task.updatedAt = new Date().toISOString();
task.log.push({ task.log.push({
timestamp: task.updatedAt, timestamp: task.updatedAt,
action: "Comment added", action: `Comment added by ${author}`,
outcome: `by ${author}`,
}); });
await this.atomicWriteTaskJson(dir, task); await this.atomicWriteTaskJson(dir, task);
@@ -2786,94 +2817,4 @@ ${notificationsSection}`;
// ── Backward Compatibility (Multi-Project Support) ──────────────────────── // ── Backward Compatibility (Multi-Project Support) ────────────────────────
/**
* Get or create a TaskStore for a project, supporting backward-compatible
* single-project mode and multi-project resolution.
*
* Resolution logic:
* - If `projectId` provided: look up in central registry, create store for that path
* - If no `projectId` and single project registered: use that project
* - If no `projectId` and multiple projects: throw requiring explicit selection
* - If no central DB available: fall back to legacy behavior (current directory)
*
* @param projectId — Optional project ID to resolve
* @param centralCore — Optional CentralCore instance (creates new if not provided)
* @returns TaskStore initialized for the resolved project
* @throws Error if project resolution fails or multiple projects require explicit selection
*/
static async getOrCreateForProject(
projectId?: string,
centralCore?: import("./central-core.js").CentralCore
): Promise<TaskStore> {
// If no centralCore provided, try to create one
let core = centralCore;
let shouldCleanupCore = false;
if (!core) {
try {
const { CentralCore } = await import("./central-core.js");
core = new CentralCore();
await core.init();
shouldCleanupCore = true;
} catch {
// Central core not available - fall back to legacy mode
}
}
// Legacy mode: no central core available
if (!core) {
const store = new TaskStore(process.cwd());
await store.init();
return store;
}
try {
// If projectId provided, look it up directly
if (projectId) {
const project = await core.getProject(projectId);
if (!project) {
// Try to find by name
const allProjects = await core.listProjects();
const byName = allProjects.find(p => p.name === projectId);
if (!byName) {
throw new Error(`Project "${projectId}" not found`);
}
const store = new TaskStore(byName.path);
await store.init();
return store;
}
const store = new TaskStore(project.path);
await store.init();
return store;
}
// No projectId provided - check registered projects
const projects = await core.listProjects();
if (projects.length === 0) {
// No projects registered - fall back to legacy mode (current directory)
const store = new TaskStore(process.cwd());
await store.init();
return store;
}
if (projects.length === 1) {
// Exactly one project - use it
const store = new TaskStore(projects[0].path);
await store.init();
return store;
}
// Multiple projects - require explicit selection
const projectList = projects.map(p => ` - ${p.name}: ${p.path}`).join("\n");
throw new Error(
`Multiple projects registered. Use --project <name> to specify one.\n\nAvailable projects:\n${projectList}`
);
} finally {
// Clean up the central core if we created it
if (shouldCleanupCore && core) {
await core.close();
}
}
}
} }

View File

@@ -1141,6 +1141,566 @@ body {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
/* === ProjectOverview Component === */
.project-overview {
display: flex;
flex-direction: column;
gap: var(--space-xl);
width: 100%;
max-width: 1440px;
margin: 0 auto;
padding: var(--space-xl);
}
.project-overview__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
flex-wrap: wrap;
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--border);
}
.project-overview__title {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--text);
}
.project-overview__stats {
display: flex;
align-items: stretch;
gap: var(--space-md);
flex: 1;
flex-wrap: wrap;
justify-content: center;
}
.project-stat {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
min-width: 120px;
padding: var(--space-sm) var(--space-md);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.project-stat--active {
border-color: rgba(88, 166, 255, 0.35);
background: rgba(88, 166, 255, 0.08);
}
.project-stat--completed {
border-color: rgba(63, 185, 80, 0.35);
background: rgba(63, 185, 80, 0.08);
}
.project-stat--error {
border-color: rgba(248, 81, 73, 0.35);
background: rgba(248, 81, 73, 0.08);
}
.project-stat__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text-muted);
flex-shrink: 0;
}
.project-stat--active .project-stat__icon {
color: var(--todo);
}
.project-stat--completed .project-stat__icon {
color: var(--color-success);
}
.project-stat--error .project-stat__icon {
color: var(--color-error);
}
.project-stat__content {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.project-stat__value {
font-family: var(--font-mono);
font-size: 18px;
font-weight: 700;
line-height: 1.1;
color: var(--text);
}
.project-stat__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
}
.project-overview__add-btn {
flex-shrink: 0;
white-space: nowrap;
}
.project-overview__filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
flex-wrap: wrap;
}
.project-filter-tabs {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.project-filter-tab {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
}
.project-filter-tab:hover {
background: var(--card-hover);
border-color: var(--text-dim);
color: var(--text);
}
.project-filter-tab.active {
background: rgba(88, 166, 255, 0.12);
border-color: rgba(88, 166, 255, 0.35);
color: var(--todo);
}
.project-filter-tab.has-errors {
border-color: rgba(248, 81, 73, 0.35);
}
.project-filter-tab.has-errors:not(.active) {
color: var(--color-error);
}
.project-filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
color: inherit;
font-size: 11px;
font-family: var(--font-mono);
}
.project-sort {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-muted);
}
.project-sort-select {
appearance: none;
background: transparent;
border: none;
color: var(--text);
font-size: 13px;
font-weight: 500;
padding: 0;
min-width: 210px;
outline: none;
cursor: pointer;
}
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--space-lg);
align-items: stretch;
}
.project-overview--empty {
min-height: 60vh;
align-items: center;
justify-content: center;
}
.project-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
max-width: 560px;
padding: var(--space-2xl);
text-align: center;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
}
.project-empty-state__icon {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--surface);
color: var(--todo);
}
.project-empty-state__title {
margin: 0;
font-size: 28px;
font-weight: 700;
color: var(--text);
}
.project-empty-state__description {
margin: 0;
font-size: 15px;
line-height: 1.6;
color: var(--text-muted);
}
.project-empty-state__cta {
margin-top: var(--space-sm);
}
.project-overview__no-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
padding: var(--space-2xl);
border: 1px dashed var(--border);
border-radius: var(--radius-xl);
color: var(--text-muted);
text-align: center;
background: color-mix(in srgb, var(--surface) 85%, transparent);
}
/* === ProjectSelector Component === */
.project-selector {
position: relative;
display: inline-block;
}
.project-selector__trigger {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
min-width: 220px;
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text);
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.project-selector__trigger:hover,
.project-selector__trigger.open {
background: var(--card-hover);
border-color: var(--text-dim);
}
.project-selector__trigger.open {
box-shadow: var(--shadow-sm);
}
.project-selector__trigger-icon {
color: var(--todo);
flex-shrink: 0;
}
.project-selector__trigger-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
font-weight: 500;
}
.project-selector__trigger-chevron {
color: var(--text-muted);
flex-shrink: 0;
transition: transform var(--transition-fast);
}
.project-selector__trigger-chevron.rotate {
transform: rotate(180deg);
}
.project-selector__dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 50;
min-width: 320px;
max-width: min(420px, 90vw);
max-height: min(70vh, 520px);
overflow-y: auto;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
}
.project-selector__search {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--card);
z-index: 1;
}
.project-selector__search-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.project-selector__search-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
color: var(--text);
font-size: 13px;
outline: none;
}
.project-selector__search-input::placeholder {
color: var(--text-dim);
}
.project-selector__search-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-muted);
cursor: pointer;
}
.project-selector__search-clear:hover {
color: var(--text);
border-color: var(--text-dim);
}
.project-selector__section {
display: flex;
flex-direction: column;
padding: var(--space-sm);
}
.project-selector__section + .project-selector__section {
border-top: 1px solid var(--border);
}
.project-selector__section-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.project-selector__item,
.project-selector__view-all {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
padding: 10px 12px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
color: var(--text);
text-align: left;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.project-selector__item:hover,
.project-selector__item.highlighted,
.project-selector__view-all:hover,
.project-selector__view-all.highlighted {
background: var(--surface);
border-color: var(--border);
}
.project-selector__item-info {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.project-selector__item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 600;
}
.project-selector__item-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: var(--text-muted);
}
.project-selector__item-check {
margin-left: auto;
color: var(--todo);
flex-shrink: 0;
}
.project-selector__no-results {
padding: var(--space-lg) var(--space-md);
color: var(--text-muted);
font-size: 13px;
text-align: center;
}
.project-selector__footer {
padding: var(--space-sm);
border-top: 1px solid var(--border);
}
.project-selector__view-all {
justify-content: center;
font-weight: 600;
color: var(--todo);
}
@media (max-width: 900px) {
.project-overview__header {
align-items: flex-start;
}
.project-overview__stats {
width: 100%;
justify-content: flex-start;
}
.project-overview__filters {
align-items: stretch;
}
.project-sort {
width: 100%;
}
.project-sort-select {
width: 100%;
min-width: 0;
}
}
@media (max-width: 640px) {
.project-overview {
padding: var(--space-lg);
}
.project-overview__title {
font-size: 20px;
}
.project-stat {
flex: 1 1 calc(50% - var(--space-sm));
min-width: 0;
}
.project-grid {
grid-template-columns: 1fr;
}
.project-selector,
.project-selector__trigger,
.project-selector__dropdown {
width: 100%;
min-width: 0;
}
.project-selector__dropdown {
left: 0;
right: 0;
max-width: none;
}
}
/* === ActivityFeed Component === */ /* === ActivityFeed Component === */
.activity-feed { .activity-feed {
display: flex; display: flex;