From b16513ca633742b659d621c500fd800de9e52545 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 29 May 2026 09:13:26 +0100 Subject: [PATCH 1/6] feat: home tab --- .../migrations/0007_remarkable_lord_tyger.sql | 6 + .../db/migrations/meta/0007_snapshot.json | 629 ++++++++++++++++++ .../src/main/db/migrations/meta/_journal.json | 9 +- .../repositories/home-workflow-repository.ts | 60 ++ apps/code/src/main/db/schema.ts | 7 + apps/code/src/main/di/container.ts | 9 + apps/code/src/main/di/tokens.ts | 3 + .../src/main/services/workflow/backend.ts | 81 +++ .../services/workflow/default-workflow.ts | 59 ++ .../src/main/services/workflow/service.ts | 111 ++++ apps/code/src/main/trpc/router.ts | 2 + apps/code/src/main/trpc/routers/workflow.ts | 36 + apps/code/src/renderer/App.tsx | 6 + .../src/renderer/components/MainLayout.tsx | 3 + .../home/components/HomeActiveAgentsStrip.tsx | 91 +++ .../home/components/HomeBoardView.tsx | 64 ++ .../home/components/HomeEmptyState.tsx | 42 ++ .../features/home/components/HomeView.tsx | 306 +++++++++ .../home/components/HomeWorkstreamCard.tsx | 236 +++++++ .../components/HomeWorkstreamDetailPanel.tsx | 371 +++++++++++ .../home/components/HomeWorkstreamRow.tsx | 193 ++++++ .../home/components/SituationChip.tsx | 16 + .../home/config/ActionEditorPanel.tsx | 172 +++++ .../features/home/config/ConfigMap.tsx | 286 ++++++++ .../home/config/SituationOverviewPanel.tsx | 155 +++++ .../features/home/config/SituationStation.tsx | 143 ++++ .../home/config/WorkflowMapArrows.tsx | 106 +++ .../features/home/config/workflowMapLayout.ts | 114 ++++ .../features/home/fixtures/demoSnapshot.ts | 423 ++++++++++++ .../features/home/hooks/useBoundActions.ts | 48 ++ .../features/home/hooks/useHomeSnapshot.ts | 211 ++++++ .../home/hooks/useRunWorkstreamAction.ts | 27 + .../features/home/hooks/useSkillsForPicker.ts | 14 + .../features/home/hooks/useWorkflow.ts | 16 + .../features/home/stores/homeDemoStore.ts | 23 + .../features/home/stores/homeUiStore.ts | 28 + .../home/stores/workflowEditorStore.ts | 169 +++++ .../renderer/features/home/subscriptions.ts | 20 + .../features/home/utils/boardColumns.ts | 61 ++ .../features/home/utils/situationDisplay.ts | 48 ++ .../sidebar/components/SidebarMenu.tsx | 16 +- .../sidebar/components/items/HomeItem.tsx | 104 +-- .../sidebar/components/items/InboxItem.tsx | 63 ++ .../sidebar/components/items/NewTaskItem.tsx | 38 ++ .../features/sidebar/hooks/useSidebarData.ts | 4 + .../src/renderer/stores/navigationStore.ts | 9 + .../src/shared/types/workflow-classify.ts | 114 ++++ .../src/shared/types/workflow-validate.ts | 60 ++ apps/code/src/shared/types/workflow.ts | 134 ++++ docs/home-tab.md | 569 ++++++++++++++++ docs/workflow-architecture.md | 230 +++++++ .../agent/tsup.config.bundled_xsmisnp8nz.mjs | 152 +++++ 52 files changed, 5815 insertions(+), 82 deletions(-) create mode 100644 apps/code/src/main/db/migrations/0007_remarkable_lord_tyger.sql create mode 100644 apps/code/src/main/db/migrations/meta/0007_snapshot.json create mode 100644 apps/code/src/main/db/repositories/home-workflow-repository.ts create mode 100644 apps/code/src/main/services/workflow/backend.ts create mode 100644 apps/code/src/main/services/workflow/default-workflow.ts create mode 100644 apps/code/src/main/services/workflow/service.ts create mode 100644 apps/code/src/main/trpc/routers/workflow.ts create mode 100644 apps/code/src/renderer/features/home/components/HomeActiveAgentsStrip.tsx create mode 100644 apps/code/src/renderer/features/home/components/HomeBoardView.tsx create mode 100644 apps/code/src/renderer/features/home/components/HomeEmptyState.tsx create mode 100644 apps/code/src/renderer/features/home/components/HomeView.tsx create mode 100644 apps/code/src/renderer/features/home/components/HomeWorkstreamCard.tsx create mode 100644 apps/code/src/renderer/features/home/components/HomeWorkstreamDetailPanel.tsx create mode 100644 apps/code/src/renderer/features/home/components/HomeWorkstreamRow.tsx create mode 100644 apps/code/src/renderer/features/home/components/SituationChip.tsx create mode 100644 apps/code/src/renderer/features/home/config/ActionEditorPanel.tsx create mode 100644 apps/code/src/renderer/features/home/config/ConfigMap.tsx create mode 100644 apps/code/src/renderer/features/home/config/SituationOverviewPanel.tsx create mode 100644 apps/code/src/renderer/features/home/config/SituationStation.tsx create mode 100644 apps/code/src/renderer/features/home/config/WorkflowMapArrows.tsx create mode 100644 apps/code/src/renderer/features/home/config/workflowMapLayout.ts create mode 100644 apps/code/src/renderer/features/home/fixtures/demoSnapshot.ts create mode 100644 apps/code/src/renderer/features/home/hooks/useBoundActions.ts create mode 100644 apps/code/src/renderer/features/home/hooks/useHomeSnapshot.ts create mode 100644 apps/code/src/renderer/features/home/hooks/useRunWorkstreamAction.ts create mode 100644 apps/code/src/renderer/features/home/hooks/useSkillsForPicker.ts create mode 100644 apps/code/src/renderer/features/home/hooks/useWorkflow.ts create mode 100644 apps/code/src/renderer/features/home/stores/homeDemoStore.ts create mode 100644 apps/code/src/renderer/features/home/stores/homeUiStore.ts create mode 100644 apps/code/src/renderer/features/home/stores/workflowEditorStore.ts create mode 100644 apps/code/src/renderer/features/home/subscriptions.ts create mode 100644 apps/code/src/renderer/features/home/utils/boardColumns.ts create mode 100644 apps/code/src/renderer/features/home/utils/situationDisplay.ts create mode 100644 apps/code/src/renderer/features/sidebar/components/items/InboxItem.tsx create mode 100644 apps/code/src/renderer/features/sidebar/components/items/NewTaskItem.tsx create mode 100644 apps/code/src/shared/types/workflow-classify.ts create mode 100644 apps/code/src/shared/types/workflow-validate.ts create mode 100644 apps/code/src/shared/types/workflow.ts create mode 100644 docs/home-tab.md create mode 100644 docs/workflow-architecture.md create mode 100644 packages/agent/tsup.config.bundled_xsmisnp8nz.mjs diff --git a/apps/code/src/main/db/migrations/0007_remarkable_lord_tyger.sql b/apps/code/src/main/db/migrations/0007_remarkable_lord_tyger.sql new file mode 100644 index 0000000000..f81209e20a --- /dev/null +++ b/apps/code/src/main/db/migrations/0007_remarkable_lord_tyger.sql @@ -0,0 +1,6 @@ +CREATE TABLE `home_workflow_config` ( + `id` text PRIMARY KEY NOT NULL, + `version` integer NOT NULL, + `json` text NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); diff --git a/apps/code/src/main/db/migrations/meta/0007_snapshot.json b/apps/code/src/main/db/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000000..f0215de664 --- /dev/null +++ b/apps/code/src/main/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,629 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ebf61ecd-8a8d-4e18-9a0d-46f6634b381e", + "prevId": "805d2ed3-331d-4ba6-8379-30f926268064", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": [ + "workspace_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": [ + "account_key", + "cloud_region" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "default_additional_directories": { + "name": "default_additional_directories", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "home_workflow_config": { + "name": "home_workflow_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "json": { + "name": "json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": [ + "workspace_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_branch": { + "name": "linked_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additional_directories": { + "name": "additional_directories", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": [ + "task_id" + ], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": [ + "repository_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": [ + "workspace_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/code/src/main/db/migrations/meta/_journal.json b/apps/code/src/main/db/migrations/meta/_journal.json index 98745d4e45..e36df25195 100644 --- a/apps/code/src/main/db/migrations/meta/_journal.json +++ b/apps/code/src/main/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1777639303535, "tag": "0006_youthful_warstar", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1779956709103, + "tag": "0007_remarkable_lord_tyger", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/code/src/main/db/repositories/home-workflow-repository.ts b/apps/code/src/main/db/repositories/home-workflow-repository.ts new file mode 100644 index 0000000000..db4e3a8911 --- /dev/null +++ b/apps/code/src/main/db/repositories/home-workflow-repository.ts @@ -0,0 +1,60 @@ +import { eq } from "drizzle-orm"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { homeWorkflowConfig } from "../schema"; +import type { DatabaseService } from "../service"; + +// Persists the Home workflow row for `LocalWorkflowBackend`. Deletion plan +// when workflow moves to PostHog: see `docs/workflow-architecture.md`. + +export interface PersistedWorkflowRow { + id: string; + version: number; + json: string; + updatedAt: string; +} + +@injectable() +export class HomeWorkflowRepository { + constructor( + @inject(MAIN_TOKENS.DatabaseService) + private readonly databaseService: DatabaseService, + ) {} + + private get db() { + return this.databaseService.db; + } + + findById(id: string): PersistedWorkflowRow | null { + const row = this.db + .select() + .from(homeWorkflowConfig) + .where(eq(homeWorkflowConfig.id, id)) + .get(); + return row ?? null; + } + + upsert(row: PersistedWorkflowRow): void { + const existing = this.findById(row.id); + if (existing) { + this.db + .update(homeWorkflowConfig) + .set({ + version: row.version, + json: row.json, + updatedAt: row.updatedAt, + }) + .where(eq(homeWorkflowConfig.id, row.id)) + .run(); + return; + } + this.db.insert(homeWorkflowConfig).values(row).run(); + } + + delete(id: string): void { + this.db + .delete(homeWorkflowConfig) + .where(eq(homeWorkflowConfig.id, id)) + .run(); + } +} diff --git a/apps/code/src/main/db/schema.ts b/apps/code/src/main/db/schema.ts index 8823ad2744..bc63fda6e2 100644 --- a/apps/code/src/main/db/schema.ts +++ b/apps/code/src/main/db/schema.ts @@ -114,3 +114,10 @@ export const authPreferences = sqliteTable( ), ], ); + +export const homeWorkflowConfig = sqliteTable("home_workflow_config", { + id: text().primaryKey(), + version: integer().notNull(), + json: text().notNull(), + updatedAt: updatedAt(), +}); diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index b2e2379419..385f3c1e47 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -5,6 +5,7 @@ import { ArchiveRepository } from "../db/repositories/archive-repository"; import { AuthPreferenceRepository } from "../db/repositories/auth-preference-repository"; import { AuthSessionRepository } from "../db/repositories/auth-session-repository"; import { DefaultAdditionalDirectoryRepository } from "../db/repositories/default-additional-directory-repository"; +import { HomeWorkflowRepository } from "../db/repositories/home-workflow-repository"; import { RepositoryRepository } from "../db/repositories/repository-repository"; import { SuspensionRepositoryImpl } from "../db/repositories/suspension-repository"; import { WorkspaceRepository } from "../db/repositories/workspace-repository"; @@ -69,6 +70,8 @@ import { UIService } from "../services/ui/service"; import { UpdatesService } from "../services/updates/service"; import { UsageMonitorService } from "../services/usage-monitor/service"; import { WatcherRegistryService } from "../services/watcher-registry/service"; +import { LocalWorkflowBackend } from "../services/workflow/backend"; +import { WorkflowService } from "../services/workflow/service"; import { WorkspaceService } from "../services/workspace/service"; import { MAIN_TOKENS } from "./tokens"; @@ -105,6 +108,7 @@ container.bind(MAIN_TOKENS.SuspensionRepository).to(SuspensionRepositoryImpl); container .bind(MAIN_TOKENS.DefaultAdditionalDirectoryRepository) .to(DefaultAdditionalDirectoryRepository); +container.bind(MAIN_TOKENS.HomeWorkflowRepository).to(HomeWorkflowRepository); container.bind(MAIN_TOKENS.AgentAuthAdapter).to(AgentAuthAdapter); container.bind(MAIN_TOKENS.AgentService).to(AgentService); container.bind(MAIN_TOKENS.AuthService).to(AuthService); @@ -153,6 +157,11 @@ container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService); container.bind(MAIN_TOKENS.InboxLinkService).to(InboxLinkService); container.bind(MAIN_TOKENS.NewTaskLinkService).to(NewTaskLinkService); container.bind(MAIN_TOKENS.WatcherRegistryService).to(WatcherRegistryService); +// Backend choice = the single swap point between POC (local SQLite) and the +// future PostHog-backed cloud workflow. See docs/workflow-architecture.md +// and apps/code/src/main/services/workflow/backend.ts for the migration plan. +container.bind(MAIN_TOKENS.WorkflowBackend).to(LocalWorkflowBackend); +container.bind(MAIN_TOKENS.WorkflowService).to(WorkflowService); container.bind(MAIN_TOKENS.WorkspaceService).to(WorkspaceService); container.bind(MAIN_TOKENS.SettingsStore).toConstantValue(settingsStore); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 69ea894b37..f23393745b 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -37,6 +37,7 @@ export const MAIN_TOKENS = Object.freeze({ DefaultAdditionalDirectoryRepository: Symbol.for( "Main.DefaultAdditionalDirectoryRepository", ), + HomeWorkflowRepository: Symbol.for("Main.HomeWorkflowRepository"), // Services AgentAuthAdapter: Symbol.for("Main.AgentAuthAdapter"), @@ -84,4 +85,6 @@ export const MAIN_TOKENS = Object.freeze({ WorkspaceService: Symbol.for("Main.WorkspaceService"), EnrichmentService: Symbol.for("Main.EnrichmentService"), UsageMonitorService: Symbol.for("Main.UsageMonitorService"), + WorkflowBackend: Symbol.for("Main.WorkflowBackend"), + WorkflowService: Symbol.for("Main.WorkflowService"), }); diff --git a/apps/code/src/main/services/workflow/backend.ts b/apps/code/src/main/services/workflow/backend.ts new file mode 100644 index 0000000000..503907dd25 --- /dev/null +++ b/apps/code/src/main/services/workflow/backend.ts @@ -0,0 +1,81 @@ +import type { HomeWorkflowRepository } from "@main/db/repositories/home-workflow-repository"; +import { MAIN_TOKENS } from "@main/di/tokens"; +import { logger } from "@main/utils/logger"; +import { type WorkflowConfig, workflowConfig } from "@shared/types/workflow"; +import { inject, injectable } from "inversify"; + +const log = logger.scope("workflow-backend"); + +/** + * Single seam between `WorkflowService` and where the workflow config lives. + * Today: local SQLite ({@link LocalWorkflowBackend}). When PostHog gains + * cross-device workflow storage, a `CloudWorkflowBackend` replaces this + * binding — see `docs/workflow-architecture.md` for the migration plan. + * + * Stability contract for any implementation: + * - `version` is monotonic. `save` is the only caller that bumps it. + * - `load()` returns `null` for "no config yet" rather than throwing. + * - Implementations validate against `workflowConfig` and drop bad rows + * on read (returning `null` so the service reseeds). + * - `delete()` is idempotent. + */ +export interface WorkflowBackend { + load(): Promise; + save(config: WorkflowConfig): Promise; + delete(): Promise; +} + +const WORKFLOW_ID = "default"; + +@injectable() +export class LocalWorkflowBackend implements WorkflowBackend { + constructor( + @inject(MAIN_TOKENS.HomeWorkflowRepository) + private readonly repository: HomeWorkflowRepository, + ) {} + + async load(): Promise { + const row = this.repository.findById(WORKFLOW_ID); + if (!row) return null; + + let raw: unknown; + try { + raw = JSON.parse(row.json); + } catch (err) { + log.warn("Persisted workflow JSON is corrupt — dropping row", { + error: err instanceof Error ? err.message : String(err), + }); + this.repository.delete(WORKFLOW_ID); + return null; + } + + // Authoritative `version` + `updatedAt` come from the row, not the JSON + // body, so a stale field in the blob can't shadow the monotonic counter. + const parsed = workflowConfig.safeParse({ + ...(typeof raw === "object" && raw !== null ? raw : {}), + version: row.version, + updatedAt: row.updatedAt, + }); + if (!parsed.success) { + log.warn("Persisted workflow no longer matches schema — dropping row", { + error: parsed.error.message, + }); + this.repository.delete(WORKFLOW_ID); + return null; + } + return parsed.data; + } + + async save(config: WorkflowConfig): Promise { + this.repository.upsert({ + id: WORKFLOW_ID, + version: config.version, + json: JSON.stringify(config), + updatedAt: config.updatedAt, + }); + } + + async delete(): Promise { + this.repository.delete(WORKFLOW_ID); + } +} diff --git a/apps/code/src/main/services/workflow/default-workflow.ts b/apps/code/src/main/services/workflow/default-workflow.ts new file mode 100644 index 0000000000..8a34dcbf7a --- /dev/null +++ b/apps/code/src/main/services/workflow/default-workflow.ts @@ -0,0 +1,59 @@ +import type { WorkflowConfig } from "@shared/types/workflow"; + +// Seed config: applied on first run and on "Reset to default". +export function buildDefaultWorkflow(): WorkflowConfig { + return { + id: "default", + version: 1, + updatedAt: new Date(0).toISOString(), + bindings: { + working: [ + { + id: "create_pr", + label: "Create PR", + skillId: "create-pr", + prompt: + "Open a PR for the current branch. Use the task history to write a concise description.", + }, + ], + in_review: [], + ci_failing: [ + { + id: "fix_ci", + label: "Fix CI", + skillId: "fix-ci", + prompt: + "CI is failing on this PR. Investigate the failing checks and push a fix.", + }, + ], + changes_requested: [ + { + id: "address_comments", + label: "Address review", + skillId: "address-comments", + prompt: + "Address the change requests on this PR — read the latest review and respond with code.", + }, + ], + comments_waiting: [ + { + id: "address_threads", + label: "Address comments", + skillId: "address-comments", + prompt: "Address the unresolved review comments on this PR.", + }, + ], + ready_to_merge: [ + { + id: "final_check", + label: "Final check", + skillId: "code-review", + prompt: + "Do a last-pass review of this PR. Call out anything risky before I merge.", + }, + ], + stale: [], + done: [], + }, + }; +} diff --git a/apps/code/src/main/services/workflow/service.ts b/apps/code/src/main/services/workflow/service.ts new file mode 100644 index 0000000000..c84ab4abf4 --- /dev/null +++ b/apps/code/src/main/services/workflow/service.ts @@ -0,0 +1,111 @@ +import { + type SaveInput, + type SaveResult, + type WorkflowConfig, + WorkflowEvent, + type WorkflowEvents, +} from "@shared/types/workflow"; +import { validateWorkflow } from "@shared/types/workflow-validate"; +import { inject, injectable, postConstruct } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import type { WorkflowBackend } from "./backend"; +import { buildDefaultWorkflow } from "./default-workflow"; + +const WORKFLOW_ID = "default"; +const log = logger.scope("workflow"); + +/** + * Owns the workflow lifecycle (load → seed default → save → emit + * `WorkflowChanged`). Storage details live behind {@link WorkflowBackend} + * so this service is the same code whether persistence is local SQLite + * or a remote PostHog API — see `docs/workflow-architecture.md`. + */ +@injectable() +export class WorkflowService extends TypedEventEmitter { + private cached: WorkflowConfig | null = null; + private inflightLoad: Promise | null = null; + + constructor( + @inject(MAIN_TOKENS.WorkflowBackend) + private readonly backend: WorkflowBackend, + ) { + super(); + } + + @postConstruct() + init(): void { + void this.get(); + } + + async get(): Promise { + if (this.cached) return this.cached; + // Dedup concurrent first-load callers behind one in-flight promise. + if (this.inflightLoad) return this.inflightLoad; + this.inflightLoad = this.loadOrSeed().finally(() => { + this.inflightLoad = null; + }); + return this.inflightLoad; + } + + async save(input: SaveInput): Promise { + const current = await this.get(); + if (current.version !== input.expectedVersion) { + return { status: "conflict", config: current }; + } + const validation = validateWorkflow(input.config); + if (!validation.canSave) { + return { + status: "invalid", + config: current, + diagnostics: validation.diagnostics, + }; + } + const next: WorkflowConfig = { + ...input.config, + id: WORKFLOW_ID, + version: current.version + 1, + updatedAt: new Date().toISOString(), + }; + await this.backend.save(next); + this.cached = next; + this.emit(WorkflowEvent.Changed, next); + log.info("Workflow saved", { + version: next.version, + actionCount: Object.values(next.bindings).reduce( + (sum, list) => sum + list.length, + 0, + ), + }); + return { status: "saved", config: next }; + } + + async resetToDefault(): Promise { + const current = await this.get(); + const fresh = buildDefaultWorkflow(); + const next: WorkflowConfig = { + ...fresh, + version: current.version + 1, + updatedAt: new Date().toISOString(), + }; + await this.backend.save(next); + this.cached = next; + this.emit(WorkflowEvent.Changed, next); + log.info("Workflow reset to default", { version: next.version }); + return next; + } + + private async loadOrSeed(): Promise { + const loaded = await this.backend.load(); + if (loaded) { + this.cached = loaded; + return loaded; + } + const seed = buildDefaultWorkflow(); + await this.backend.save(seed); + this.cached = seed; + log.info("Seeded default workflow", { version: seed.version }); + return seed; + } +} diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index f0f8dd9eb5..53449b0297 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -37,6 +37,7 @@ import { suspensionRouter } from "./routers/suspension.js"; import { uiRouter } from "./routers/ui"; import { updatesRouter } from "./routers/updates"; import { usageMonitorRouter } from "./routers/usage-monitor"; +import { workflowRouter } from "./routers/workflow"; import { workspaceRouter } from "./routers/workspace"; import { router } from "./trpc"; @@ -81,6 +82,7 @@ export const trpcRouter = router({ updates: updatesRouter, usageMonitor: usageMonitorRouter, deepLink: deepLinkRouter, + workflow: workflowRouter, workspace: workspaceRouter, }); diff --git a/apps/code/src/main/trpc/routers/workflow.ts b/apps/code/src/main/trpc/routers/workflow.ts new file mode 100644 index 0000000000..fdb4420e9c --- /dev/null +++ b/apps/code/src/main/trpc/routers/workflow.ts @@ -0,0 +1,36 @@ +import { + saveInput, + saveResult, + WorkflowEvent, + type WorkflowEvents, + workflowConfig, +} from "@shared/types/workflow"; +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import type { WorkflowService } from "../../services/workflow/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.WorkflowService); + +function subscribe(event: K) { + return publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(event, { signal: opts.signal }); + for await (const data of iterable) { + yield data; + } + }); +} + +export const workflowRouter = router({ + get: publicProcedure.output(workflowConfig).query(() => getService().get()), + save: publicProcedure + .input(saveInput) + .output(saveResult) + .mutation(({ input }) => getService().save(input)), + resetToDefault: publicProcedure + .output(workflowConfig) + .mutation(() => getService().resetToDefault()), + onChanged: subscribe(WorkflowEvent.Changed), +}); diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index 03e55f4c79..70dd9173fb 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -14,6 +14,7 @@ import { useAuthSession } from "@features/auth/hooks/useAuthSession"; import { useIsOrgAdmin } from "@features/auth/hooks/useOrgRole"; import { registerBillingSubscriptions } from "@features/billing/subscriptions"; import { AddDirectoryDialog } from "@features/folder-picker/components/AddDirectoryDialog"; +import { registerHomeSubscriptions } from "@features/home/subscriptions"; import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { Flex, Spinner, Text } from "@radix-ui/themes"; @@ -75,6 +76,11 @@ function App() { return registerBillingSubscriptions(); }, [isAuthenticated]); + useEffect(() => { + if (!isAuthenticated) return; + return registerHomeSubscriptions(); + }, [isAuthenticated]); + // Initialize update store useEffect(() => { return initializeUpdateStore(); diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index ff1d04eecf..42a9133feb 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -7,6 +7,7 @@ import { ArchivedTasksView } from "@features/archive/components/ArchivedTasksVie import { UsageLimitModal } from "@features/billing/components/UsageLimitModal"; import { CommandMenu } from "@features/command/components/CommandMenu"; import { CommandCenterView } from "@features/command-center/components/CommandCenterView"; +import { HomeView } from "@features/home/components/HomeView"; import { InboxView } from "@features/inbox/components/InboxView"; import { useInboxDeepLink } from "@features/inbox/hooks/useInboxDeepLink"; import { McpServersView } from "@features/mcp-servers/components/McpServersView"; @@ -167,6 +168,8 @@ export function MainLayout() { {view.type === "folder-settings" && } + {view.type === "home" && } + {view.type === "inbox" && } {view.type === "archived" && } diff --git a/apps/code/src/renderer/features/home/components/HomeActiveAgentsStrip.tsx b/apps/code/src/renderer/features/home/components/HomeActiveAgentsStrip.tsx new file mode 100644 index 0000000000..8416c7067c --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeActiveAgentsStrip.tsx @@ -0,0 +1,91 @@ +import { CircleNotch, GitBranch, Warning } from "@phosphor-icons/react"; +import { Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { useTasks } from "@renderer/features/tasks/hooks/useTasks"; +import { useNavigationStore } from "@stores/navigationStore"; +import { formatRelativeTimeShort } from "@utils/time"; +import type { HomeActiveAgent } from "../hooks/useHomeSnapshot"; + +interface HomeActiveAgentsStripProps { + agents: HomeActiveAgent[]; +} + +export function HomeActiveAgentsStrip({ agents }: HomeActiveAgentsStripProps) { + const { data: tasks = [] } = useTasks(); + const navigateToTask = useNavigationStore((s) => s.navigateToTask); + + if (agents.length === 0) return null; + + return ( + + + + + Running ({agents.length}) + + + + + {agents.map((agent) => { + const task = tasks.find((t) => t.id === agent.taskId); + return ( + + ); + })} + + + + ); +} diff --git a/apps/code/src/renderer/features/home/components/HomeBoardView.tsx b/apps/code/src/renderer/features/home/components/HomeBoardView.tsx new file mode 100644 index 0000000000..263ae067cd --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeBoardView.tsx @@ -0,0 +1,64 @@ +import { Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import type { HomeSnapshot } from "../hooks/useHomeSnapshot"; +import { buildBoardColumns } from "../utils/boardColumns"; +import { HomeWorkstreamCard } from "./HomeWorkstreamCard"; + +interface HomeBoardViewProps { + snapshot: HomeSnapshot; +} + +export function HomeBoardView({ snapshot }: HomeBoardViewProps) { + const columns = buildBoardColumns( + snapshot.needsAttention, + snapshot.inProgress, + ); + + return ( + + + {columns.map((column) => ( + + + + + {column.title} + + + {column.workstreams.length} + + + + {column.description} + + + + {column.workstreams.length === 0 ? ( + + + Nothing here + + + ) : ( + column.workstreams.map((ws) => ( + + )) + )} + + + ))} + + + ); +} diff --git a/apps/code/src/renderer/features/home/components/HomeEmptyState.tsx b/apps/code/src/renderer/features/home/components/HomeEmptyState.tsx new file mode 100644 index 0000000000..b140e27cb4 --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeEmptyState.tsx @@ -0,0 +1,42 @@ +import { CheckCircle, Plus } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Flex, Text } from "@radix-ui/themes"; +import { useNavigationStore } from "@stores/navigationStore"; + +interface HomeEmptyStateProps { + hasRunningAgents: boolean; +} + +export function HomeEmptyState({ hasRunningAgents }: HomeEmptyStateProps) { + const navigateToTaskInput = useNavigationStore((s) => s.navigateToTaskInput); + + return ( + + + + You're caught up + + + {hasRunningAgents + ? "Nothing else needs your attention. Your active agents are working." + : "Nothing needs your attention right now. Start something new when you're ready."} + + {!hasRunningAgents ? ( + + ) : null} + + ); +} diff --git a/apps/code/src/renderer/features/home/components/HomeView.tsx b/apps/code/src/renderer/features/home/components/HomeView.tsx new file mode 100644 index 0000000000..32a679fc87 --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeView.tsx @@ -0,0 +1,306 @@ +import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; +import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; +import { + Flask, + Graph, + House, + Kanban, + ListBullets, + Warning, +} from "@phosphor-icons/react"; +import { Badge, Button } from "@posthog/quill"; +import { Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { useEffect, useMemo } from "react"; +import { ConfigMap } from "../config/ConfigMap"; +import { useHomeSnapshot } from "../hooks/useHomeSnapshot"; +import { + type HomeDemoScenario, + useHomeDemoStore, +} from "../stores/homeDemoStore"; +import { type HomeViewMode, useHomeUiStore } from "../stores/homeUiStore"; +import { HomeActiveAgentsStrip } from "./HomeActiveAgentsStrip"; +import { HomeBoardView } from "./HomeBoardView"; +import { HomeEmptyState } from "./HomeEmptyState"; +import { HomeWorkstreamDetailPanel } from "./HomeWorkstreamDetailPanel"; +import { HomeWorkstreamRow } from "./HomeWorkstreamRow"; + +const VIEW_CYCLE: HomeViewMode[] = ["list", "board", "config"]; + +export function HomeView() { + const { snapshot, isLoading, isDemo } = useHomeSnapshot(); + const demoScenario = useHomeDemoStore((s) => s.scenario); + const setDemoScenario = useHomeDemoStore((s) => s.setScenario); + const viewMode = useHomeUiStore((s) => s.viewMode); + const setViewMode = useHomeUiStore((s) => s.setViewMode); + const selectedWorkstreamId = useHomeUiStore((s) => s.selectedWorkstreamId); + const setSelectedWorkstreamId = useHomeUiStore( + (s) => s.setSelectedWorkstreamId, + ); + + const headerContent = useMemo( + () => ( + + + + Home + + + ), + [], + ); + useSetHeaderContent(headerContent); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key !== "v" || e.metaKey || e.ctrlKey || e.altKey) return; + // Don't capture `v` while the user is typing. + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { + return; + } + const idx = VIEW_CYCLE.indexOf(viewMode); + setViewMode(VIEW_CYCLE[(idx + 1) % VIEW_CYCLE.length] ?? "list"); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [viewMode, setViewMode]); + + useEffect(() => { + if (!selectedWorkstreamId) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setSelectedWorkstreamId(null); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [selectedWorkstreamId, setSelectedWorkstreamId]); + + if (isLoading) { + return ( + + + + ); + } + + const { activeAgents, needsAttention, inProgress } = snapshot; + const totalRows = needsAttention.length + inProgress.length; + const hasContent = activeAgents.length > 0 || totalRows > 0; + + const selectedWorkstream = selectedWorkstreamId + ? (needsAttention.find((ws) => ws.id === selectedWorkstreamId) ?? + inProgress.find((ws) => ws.id === selectedWorkstreamId) ?? + null) + : null; + + const summary = [ + needsAttention.length > 0 + ? `${needsAttention.length} need${needsAttention.length === 1 ? "s" : ""} attention` + : null, + activeAgents.length > 0 ? `${activeAgents.length} running` : null, + inProgress.length > 0 ? `${inProgress.length} in progress` : null, + ] + .filter(Boolean) + .join(" · "); + + return ( + + + + + + + Home + + {isDemo ? ( + + Demo data + + ) : null} + + + {summary || "You're caught up"} + + + + + {viewMode !== "config" ? ( + + ) : null} + + + + + {viewMode === "config" ? ( + + + + ) : ( + <> + + + + {!hasContent && totalRows === 0 ? ( + 0} /> + ) : viewMode === "board" ? ( + + + + ) : ( + + {needsAttention.length > 0 ? ( +
+ } + count={needsAttention.length} + > + {needsAttention.map((ws) => ( + + ))} +
+ ) : null} + + {inProgress.length > 0 ? ( +
+ {inProgress.map((ws) => ( + + ))} +
+ ) : null} + + {totalRows === 0 && activeAgents.length > 0 ? ( + + ) : null} +
+ )} +
+ {selectedWorkstream ? ( + + setSelectedWorkstreamId(null)} + /> + + ) : null} +
+ + )} +
+ ); +} + +interface SectionProps { + title: string; + count: number; + icon?: React.ReactNode; + children: React.ReactNode; +} + +const DEMO_OPTIONS: { value: HomeDemoScenario; label: string }[] = [ + { value: "off", label: "Real" }, + { value: "populated", label: "Demo" }, + { value: "empty", label: "Empty" }, +]; + +interface DemoScenarioPickerProps { + value: HomeDemoScenario; + onChange: (next: HomeDemoScenario) => void; +} + +function DemoScenarioPicker({ value, onChange }: DemoScenarioPickerProps) { + return ( + + + {DEMO_OPTIONS.map((opt) => ( + + ))} + + ); +} + +interface ViewModeToggleProps { + value: HomeViewMode; + onChange: (next: HomeViewMode) => void; +} + +function ViewModeToggle({ value, onChange }: ViewModeToggleProps) { + return ( + + + + + + ); +} + +function Section({ title, count, icon, children }: SectionProps) { + return ( + + + {icon} + + {title} + + ({count}) + + {children} + + ); +} diff --git a/apps/code/src/renderer/features/home/components/HomeWorkstreamCard.tsx b/apps/code/src/renderer/features/home/components/HomeWorkstreamCard.tsx new file mode 100644 index 0000000000..ded9b6ad76 --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeWorkstreamCard.tsx @@ -0,0 +1,236 @@ +import { + CaretDown, + CheckCircle, + GitBranch, + Sparkle, + Warning, + XCircle, +} from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Box, DropdownMenu, Flex, Text } from "@radix-ui/themes"; +import { useTasks } from "@renderer/features/tasks/hooks/useTasks"; +import { useNavigationStore } from "@stores/navigationStore"; +import { openUrlInBrowser } from "@utils/browser"; +import { formatRelativeTimeShort } from "@utils/time"; +import { type BoundAction, useBoundActions } from "../hooks/useBoundActions"; +import type { HomePullRequest, HomeWorkstream } from "../hooks/useHomeSnapshot"; +import { useRunWorkstreamAction } from "../hooks/useRunWorkstreamAction"; +import { useHomeUiStore } from "../stores/homeUiStore"; +import { + severityRingClass, + situationSeverity, +} from "../utils/situationDisplay"; +import { SituationChip } from "./SituationChip"; + +interface HomeWorkstreamCardProps { + workstream: HomeWorkstream; +} + +function CiDot({ status }: { status: HomePullRequest["ciStatus"] }) { + if (status === "passing") { + return ( + + + + ); + } + if (status === "failing") { + return ( + + + + ); + } + if (status === "pending") { + return ( + + ); + } + return null; +} + +export function HomeWorkstreamCard({ workstream }: HomeWorkstreamCardProps) { + const { data: tasks = [] } = useTasks(); + const navigateToTask = useNavigationStore((s) => s.navigateToTask); + const boundActions = useBoundActions(workstream); + const runAction = useRunWorkstreamAction(); + const setSelectedWorkstreamId = useHomeUiStore( + (s) => s.setSelectedWorkstreamId, + ); + const isSelected = useHomeUiStore( + (s) => s.selectedWorkstreamId === workstream.id, + ); + + const headTask = workstream.tasks[0]; + const pr = workstream.pr; + const title = + pr?.title ?? headTask?.title ?? workstream.branch ?? "Workstream"; + const taskCount = workstream.tasks.length; + const severity = situationSeverity(workstream.situations); + const primaryBound = boundActions[0] ?? null; + const overflowBound = boundActions.slice(1); + + function handleRunAction(action: BoundAction) { + runAction(action, workstream); + } + + function handleOpenTask() { + if (!headTask) return; + const task = tasks.find((t) => t.id === headTask.id); + if (task) navigateToTask(task); + } + + function handleOpenPr() { + if (workstream.prUrl) void openUrlInBrowser(workstream.prUrl); + } + + return ( + setSelectedWorkstreamId(workstream.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelectedWorkstreamId(workstream.id); + } + }} + role="button" + tabIndex={0} + aria-label={`Open ${title}`} + className={`cursor-pointer rounded-md border border-(--gray-4) bg-(--color-panel-solid) p-3 transition-colors hover:border-(--gray-6) hover:shadow-sm ${ + isSelected ? "border-(--accent-9) ring-(--accent-7) ring-2" : "" + } ${severityRingClass(severity)}`} + > + + + + {title} + + {pr ? : null} + + + + {workstream.repoName ? ( + {workstream.repoName} + ) : null} + {workstream.branch ? ( + + + + {workstream.branch} + + + ) : null} + {pr ? #{pr.number} : null} + {formatRelativeTimeShort(workstream.lastActivityAt)} + + + {workstream.situations.length > 0 ? ( + + {workstream.situations.map((sid) => ( + + ))} + + ) : null} + + {pr?.reviewDecision === "changes_requested" ? ( + + + Changes requested + + ) : null} + {pr?.reviewDecision === "approved" ? ( + + + Approved + + ) : null} + + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {primaryBound ? ( + + + {overflowBound.length > 0 ? ( + + + + + + {overflowBound.map((action) => ( + handleRunAction(action)} + > + + {action.label} + + {action.situationLabel} + + + ))} + + + ) : null} + + ) : workstream.prUrl ? ( + + ) : ( + + )} + {taskCount > 1 ? ( + + {taskCount} tasks + + ) : null} + + + + ); +} diff --git a/apps/code/src/renderer/features/home/components/HomeWorkstreamDetailPanel.tsx b/apps/code/src/renderer/features/home/components/HomeWorkstreamDetailPanel.tsx new file mode 100644 index 0000000000..4e7d876793 --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeWorkstreamDetailPanel.tsx @@ -0,0 +1,371 @@ +import { + ArrowSquareOut, + CaretDown, + CaretRight, + ChatCircle, + CheckCircle, + CircleDashed, + GitBranch, + GitPullRequest, + Sparkle, + Spinner, + Warning, + X, + XCircle, +} from "@phosphor-icons/react"; +import { Badge, Button } from "@posthog/quill"; +import { Box, DropdownMenu, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { useTasks } from "@renderer/features/tasks/hooks/useTasks"; +import type { TaskRunStatus } from "@shared/types"; +import { useNavigationStore } from "@stores/navigationStore"; +import { openUrlInBrowser } from "@utils/browser"; +import { formatRelativeTimeShort } from "@utils/time"; +import { type BoundAction, useBoundActions } from "../hooks/useBoundActions"; +import type { + HomePullRequest, + HomeWorkstream, + HomeWorkstreamTask, +} from "../hooks/useHomeSnapshot"; +import { useRunWorkstreamAction } from "../hooks/useRunWorkstreamAction"; +import { SituationChip } from "./SituationChip"; + +interface Props { + workstream: HomeWorkstream; + onClose: () => void; +} + +export function HomeWorkstreamDetailPanel({ workstream, onClose }: Props) { + const { data: allTasks = [] } = useTasks(); + const navigateToTask = useNavigationStore((s) => s.navigateToTask); + const boundActions = useBoundActions(workstream); + const runAction = useRunWorkstreamAction(); + + const pr = workstream.pr; + const headTask = workstream.tasks[0]; + const title = + pr?.title ?? headTask?.title ?? workstream.branch ?? "Workstream"; + + function handleOpenTask(task: HomeWorkstreamTask) { + const found = allTasks.find((t) => t.id === task.id); + if (found) navigateToTask(found); + } + + function handleOpenPr() { + if (workstream.prUrl) void openUrlInBrowser(workstream.prUrl); + } + + const primaryAction = boundActions[0] ?? null; + const overflowActions = boundActions.slice(1); + + return ( + + {/* Header */} + + + + {title} + + + {workstream.repoName ? {workstream.repoName} : null} + {workstream.branch ? ( + + + + {workstream.branch} + + + ) : null} + {pr ? #{pr.number} : null} + {formatRelativeTimeShort(workstream.lastActivityAt)} + + + + + + + + {workstream.situations.length > 0 ? ( +
+ + {workstream.situations.map((sid) => ( + + ))} + +
+ ) : null} + + {pr ? : null} + + {boundActions.length > 0 ? ( +
+ + {primaryAction ? ( + + ) : null} + {overflowActions.length > 0 ? ( + + + + + + {overflowActions.map((action: BoundAction) => ( + runAction(action, workstream)} + > + + {action.label} + + {action.situationLabel} + + + ))} + + + ) : null} + +
+ ) : null} + +
+ + {workstream.tasks.map((task) => ( + handleOpenTask(task)} + /> + ))} + +
+
+
+ + {/* Footer links */} + {workstream.prUrl ? ( + + + + ) : null} +
+ ); +} + +interface SectionProps { + title: string; + subtitle?: string; + children: React.ReactNode; +} + +function Section({ title, subtitle, children }: SectionProps) { + return ( + + + + {title} + + {subtitle ? ( + {subtitle} + ) : null} + + {children} + + ); +} + +function PrBlock({ pr, onOpen }: { pr: HomePullRequest; onOpen: () => void }) { + return ( +
+ + + + + + {pr.reviewDecision === "approved" ? ( + + + Approved + + ) : null} + {pr.reviewDecision === "changes_requested" ? ( + + + Changes requested + + ) : null} + {pr.unresolvedThreads > 0 ? ( + + + + {pr.unresolvedThreads} unresolved review thread + {pr.unresolvedThreads === 1 ? "" : "s"} + + + ) : null} + {pr.author && !pr.isCurrentUserAuthor ? ( + by @{pr.author} + ) : null} + + +
+ ); +} + +function PrStatePill({ pr }: { pr: HomePullRequest }) { + if (pr.state === "merged") return Merged; + if (pr.state === "draft") return Draft; + if (pr.state === "closed") return Closed; + return Open; +} + +function CiBadge({ status }: { status: HomePullRequest["ciStatus"] }) { + if (status === "passing") { + return ( + + + CI passing + + ); + } + if (status === "failing") { + return ( + + + CI failing + + ); + } + if (status === "pending") { + return ( + + + CI pending + + ); + } + return null; +} + +function TaskRow({ + task, + onClick, +}: { + task: HomeWorkstreamTask; + onClick: () => void; +}) { + return ( + + ); +} + +function TaskStatusIcon({ + status, + isGenerating, +}: { + status: TaskRunStatus | undefined; + isGenerating: boolean; +}) { + if (isGenerating || status === "in_progress" || status === "queued") { + return ( + + ); + } + if (status === "completed") { + return ( + + ); + } + if (status === "failed") { + return ( + + ); + } + if (status === "cancelled") { + return ; + } + return ; +} diff --git a/apps/code/src/renderer/features/home/components/HomeWorkstreamRow.tsx b/apps/code/src/renderer/features/home/components/HomeWorkstreamRow.tsx new file mode 100644 index 0000000000..8c67d1b1ee --- /dev/null +++ b/apps/code/src/renderer/features/home/components/HomeWorkstreamRow.tsx @@ -0,0 +1,193 @@ +import { + ArrowSquareOut, + CaretDown, + ChatCircle, + GitBranch, + GitPullRequest, + Sparkle, + Warning, +} from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Box, DropdownMenu, Flex, Text } from "@radix-ui/themes"; +import { useTasks } from "@renderer/features/tasks/hooks/useTasks"; +import { useNavigationStore } from "@stores/navigationStore"; +import { openUrlInBrowser } from "@utils/browser"; +import { formatRelativeTimeShort } from "@utils/time"; +import { type BoundAction, useBoundActions } from "../hooks/useBoundActions"; +import type { HomeWorkstream } from "../hooks/useHomeSnapshot"; +import { useRunWorkstreamAction } from "../hooks/useRunWorkstreamAction"; +import { useHomeUiStore } from "../stores/homeUiStore"; +import { + severityRingClass, + situationSeverity, +} from "../utils/situationDisplay"; +import { SituationChip } from "./SituationChip"; + +interface HomeWorkstreamRowProps { + workstream: HomeWorkstream; +} + +export function HomeWorkstreamRow({ workstream }: HomeWorkstreamRowProps) { + const { data: tasks = [] } = useTasks(); + const navigateToTask = useNavigationStore((s) => s.navigateToTask); + const boundActions = useBoundActions(workstream); + const runAction = useRunWorkstreamAction(); + const setSelectedWorkstreamId = useHomeUiStore( + (s) => s.setSelectedWorkstreamId, + ); + const isSelected = useHomeUiStore( + (s) => s.selectedWorkstreamId === workstream.id, + ); + + const headTask = workstream.tasks[0]; + const title = headTask?.title ?? workstream.branch ?? "Workstream"; + const taskCount = workstream.tasks.length; + const severity = situationSeverity(workstream.situations); + const inlineActions = boundActions.slice(0, 2); + const overflowActions = boundActions.slice(2); + + function handleRunAction(action: BoundAction) { + runAction(action, workstream); + } + + function handleOpenTask() { + if (!headTask) return; + const task = tasks.find((t) => t.id === headTask.id); + if (task) navigateToTask(task); + } + + function handleOpenPr() { + if (workstream.prUrl) void openUrlInBrowser(workstream.prUrl); + } + + return ( + setSelectedWorkstreamId(workstream.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelectedWorkstreamId(workstream.id); + } + }} + role="button" + tabIndex={0} + aria-label={`Open ${title}`} + className={`cursor-pointer border-(--gray-4) border-b px-5 py-3 transition-colors hover:bg-(--gray-2) ${ + isSelected ? "bg-(--accent-3)" : "" + } ${severityRingClass(severity)}`} + > + + + + + {title} + + {workstream.situations.map((sid) => ( + + ))} + {taskCount > 1 ? ( + + · {taskCount} tasks + + ) : null} + + + + {workstream.repoName ? ( + {workstream.repoName} + ) : null} + {workstream.branch ? ( + + + + {workstream.branch} + + + ) : null} + {headTask?.needsPermission ? ( + + + Awaiting permission + + ) : null} + {workstream.tasks.some((t) => t.isGenerating) ? ( + + + Generating + + ) : null} + {formatRelativeTimeShort(workstream.lastActivityAt)} + + + + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {inlineActions.map((action, idx) => ( + + ))} + {overflowActions.length > 0 ? ( + + + + + + {overflowActions.map((action) => ( + handleRunAction(action)} + > + + {action.label} + + {action.situationLabel} + + + ))} + + + ) : null} + {workstream.prUrl ? ( + + ) : null} + + + + + ); +} diff --git a/apps/code/src/renderer/features/home/components/SituationChip.tsx b/apps/code/src/renderer/features/home/components/SituationChip.tsx new file mode 100644 index 0000000000..3b456e284d --- /dev/null +++ b/apps/code/src/renderer/features/home/components/SituationChip.tsx @@ -0,0 +1,16 @@ +import { Badge } from "@posthog/quill"; +import type { SituationId } from "@shared/types/workflow"; +import { SITUATION_BADGE, SITUATION_META } from "../utils/situationDisplay"; + +interface Props { + sid: SituationId; +} + +export function SituationChip({ sid }: Props) { + const meta = SITUATION_META[sid]; + return ( + + {meta.label} + + ); +} diff --git a/apps/code/src/renderer/features/home/config/ActionEditorPanel.tsx b/apps/code/src/renderer/features/home/config/ActionEditorPanel.tsx new file mode 100644 index 0000000000..86a3800e26 --- /dev/null +++ b/apps/code/src/renderer/features/home/config/ActionEditorPanel.tsx @@ -0,0 +1,172 @@ +import { useSkillsForPicker } from "@features/home/hooks/useSkillsForPicker"; +import { useWorkflowEditorStore } from "@features/home/stores/workflowEditorStore"; +import { ArrowDown, ArrowUp, Trash, X } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Card, Flex, Text, TextArea, TextField } from "@radix-ui/themes"; +import { + SITUATIONS, + type SituationId, + type WorkflowAction, +} from "@shared/types/workflow"; +import { SITUATION_TONE } from "./workflowMapLayout"; + +interface Props { + situationId: SituationId; + action: WorkflowAction; + totalInSituation: number; + indexInSituation: number; +} + +export function ActionEditorPanel({ + situationId, + action, + totalInSituation, + indexInSituation, +}: Props) { + const updateAction = useWorkflowEditorStore((s) => s.updateAction); + const removeAction = useWorkflowEditorStore((s) => s.removeAction); + const moveAction = useWorkflowEditorStore((s) => s.moveAction); + const selectSituation = useWorkflowEditorStore((s) => s.selectSituation); + + const { skills, isLoading } = useSkillsForPicker(); + const selectedSkill = skills.find((s) => s.name === action.skillId) ?? null; + + const meta = SITUATIONS.find((s) => s.id === situationId); + const tone = SITUATION_TONE[situationId]; + + function patch(p: Partial) { + updateAction(situationId, action.id, p); + } + + function handleRemove() { + removeAction(situationId, action.id); + // Fall back to the situation overview so the user keeps context after + // deleting the action they were editing. + selectSituation(situationId); + } + + return ( + + + + + {meta?.label} + + Edit action + + + + +
+ + + patch({ label: e.target.value })} + /> + + + + + {selectedSkill ? ( + + {selectedSkill.description} + + ) : null} + + + +