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:
5
.changeset/fn-680-taskstore-regressions.md
Normal file
5
.changeset/fn-680-taskstore-regressions.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix project resolution and fresh-schema task persistence regressions.
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user