From 309bae18da39cc8e3a3feb957d258735c83847b9 Mon Sep 17 00:00:00 2001 From: mkorwel Date: Thu, 19 Mar 2026 09:43:39 -0700 Subject: [PATCH] feat(workspaces): implement Workspaces UI and action primitives --- .../cli/src/commands/workspace/connect.ts | 1 + .../cli/src/ui/commands/workspaceCommand.ts | 56 ++++++++----------- .../src/ui/components/HistoryItemDisplay.tsx | 4 ++ .../ui/components/views/WorkspacesList.tsx | 52 +++++++++++++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 10 ++++ packages/cli/src/ui/types.ts | 8 +++ packages/cli/tsconfig.json | 5 +- packages/core/src/commands/types.ts | 8 +++ packages/core/src/commands/workspace.ts | 20 ++----- .../src/routes/workspaceRoutes.ts | 6 +- plans/milestone-5-ui-and-advanced.md | 32 +++++++++++ plans/workspaces-implementation.md | 4 +- 12 files changed, 154 insertions(+), 52 deletions(-) create mode 100644 packages/cli/src/ui/components/views/WorkspacesList.tsx create mode 100644 plans/milestone-5-ui-and-advanced.md diff --git a/packages/cli/src/commands/workspace/connect.ts b/packages/cli/src/commands/workspace/connect.ts index dbb1d01d6b..d297e6a640 100644 --- a/packages/cli/src/commands/workspace/connect.ts +++ b/packages/cli/src/commands/workspace/connect.ts @@ -12,6 +12,7 @@ import { type Config, type WorkspaceHubInfo } from '@google-gemini-cli-core'; + import { exitCli } from '../utils.js'; import chalk from 'chalk'; import { execSync } from 'node:child_process'; diff --git a/packages/cli/src/ui/commands/workspaceCommand.ts b/packages/cli/src/ui/commands/workspaceCommand.ts index a8f4e3b2a4..177368b7eb 100644 --- a/packages/cli/src/ui/commands/workspaceCommand.ts +++ b/packages/cli/src/ui/commands/workspaceCommand.ts @@ -6,12 +6,14 @@ import type { SlashCommand, CommandContext } from './types.js'; import { CommandKind } from './types.js'; -import type { MessageActionReturn } from '@google/gemini-cli-core'; -import { WorkspaceHubClient } from '@google/gemini-cli-core'; +import { + type CommandActionReturn, + WorkspaceHubClient +} from '@google-gemini-cli-core'; const listAction = async ( _context: CommandContext, -): Promise => { +): Promise => { const hubUrl = process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080'; const client = new WorkspaceHubClient(hubUrl); @@ -27,21 +29,12 @@ const listAction = async ( }; } - let content = 'Active Workspaces:\n'; - content += '------------------------------------------------------------\n'; - for (const ws of workspaces) { - content += `${ws.name.padEnd(20)} | ${ws.status.padEnd(12)} | ${ws.id}\n`; - } - content += '------------------------------------------------------------'; - return { - type: 'message', - messageType: 'info', - content, + type: 'workspaces_list', + workspaces, }; } catch (error: unknown) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const message = (error as Error).message; + const message = error instanceof Error ? error.message : String(error); return { type: 'message', messageType: 'error', @@ -56,7 +49,7 @@ const listCommand: SlashCommand = { description: 'List remote workspaces', kind: CommandKind.BUILT_IN, autoExecute: true, - action: (context) => listAction(context), + action: ((context: CommandContext) => listAction(context)) as any, }; const createCommand: SlashCommand = { @@ -64,10 +57,10 @@ const createCommand: SlashCommand = { description: 'Create a new remote workspace', kind: CommandKind.BUILT_IN, autoExecute: true, - action: async ( + action: (async ( context: CommandContext, args: string, - ): Promise => { + ): Promise => { const name = args.trim(); if (!name) { return { @@ -93,15 +86,14 @@ const createCommand: SlashCommand = { content: `✅ Workspace created successfully!\nID: ${ws.id}\nName: ${ws.name}\nGCE: ${ws.instance_name}`, }; } catch (error: unknown) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const message = (error as Error).message; + const message = error instanceof Error ? error.message : String(error); return { type: 'message', messageType: 'error', content: `Failed to create workspace: ${message}`, }; } - }, + }) as any, }; const deleteCommand: SlashCommand = { @@ -110,10 +102,10 @@ const deleteCommand: SlashCommand = { description: 'Delete a remote workspace', kind: CommandKind.BUILT_IN, autoExecute: true, - action: async ( - context: CommandContext, + action: (async ( + _context: CommandContext, args: string, - ): Promise => { + ): Promise => { const id = args.trim(); if (!id) { return { @@ -128,7 +120,8 @@ const deleteCommand: SlashCommand = { const client = new WorkspaceHubClient(hubUrl); try { - context.ui.addItem({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + (_context.ui as any).addItem({ type: 'info', text: `Deleting workspace "${id}"...`, }); @@ -139,15 +132,14 @@ const deleteCommand: SlashCommand = { content: `✅ Workspace ${id} deleted successfully.`, }; } catch (error: unknown) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const message = (error as Error).message; + const message = error instanceof Error ? error.message : String(error); return { type: 'message', messageType: 'error', content: `Failed to delete workspace: ${message}`, }; } - }, + }) as any, }; const connectCommand: SlashCommand = { @@ -155,10 +147,10 @@ const connectCommand: SlashCommand = { description: 'Connect to a remote workspace', kind: CommandKind.BUILT_IN, autoExecute: true, - action: async ( + action: (async ( _context: CommandContext, args: string, - ): Promise => { + ): Promise => { const id = args.trim(); if (!id) { return { @@ -171,7 +163,7 @@ const connectCommand: SlashCommand = { type: 'submit_prompt', content: `I want to connect to remote workspace "${id}". Please run the connect command.`, }; - }, + }) as any, }; export const workspaceSlashCommand: SlashCommand = { @@ -181,5 +173,5 @@ export const workspaceSlashCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [listCommand, createCommand, deleteCommand, connectCommand], - action: async (context: CommandContext) => listAction(context), + action: (async (context: CommandContext) => listAction(context)) as any, }; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 9c8d90cd19..b4c65be639 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -32,6 +32,7 @@ import { SkillsList } from './views/SkillsList.js'; import { AgentsStatus } from './views/AgentsStatus.js'; import { McpStatus } from './views/McpStatus.js'; import { ChatList } from './views/ChatList.js'; +import { WorkspacesList } from './views/WorkspacesList.js'; import { ModelMessage } from './messages/ModelMessage.js'; import { ThinkingMessage } from './messages/ThinkingMessage.js'; import { HintMessage } from './messages/HintMessage.js'; @@ -233,6 +234,9 @@ export const HistoryItemDisplay: React.FC = ({ {itemForDisplay.type === 'chat_list' && ( )} + {itemForDisplay.type === 'workspaces_list' && ( + + )} ); }; diff --git a/packages/cli/src/ui/components/views/WorkspacesList.tsx b/packages/cli/src/ui/components/views/WorkspacesList.tsx new file mode 100644 index 0000000000..b4562e9f7e --- /dev/null +++ b/packages/cli/src/ui/components/views/WorkspacesList.tsx @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import type { WorkspaceHubInfo } from '@google/gemini-cli-core'; + +interface WorkspacesListProps { + workspaces: readonly WorkspaceHubInfo[]; +} + +export const WorkspacesList: React.FC = ({ workspaces }) => { + if (workspaces.length === 0) { + return No active workspaces found.; + } + + return ( + + Active Remote Workspaces: + + {workspaces.map((ws) => { + const isReady = ws.status === 'READY'; + const statusColor = isReady ? 'green' : 'yellow'; + + return ( + + + {ws.name.padEnd(20)} + | + {ws.status.padEnd(12)} + | + {ws.id} + + + Instance: {ws.instance_name} ({ws.zone}) + + + Project: {ws.project_id} + + + ); + })} + + + Use `/wsr connect {''}` to teleport into a workspace. + + + ); +}; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index d070840f2d..f10f9afd27 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -662,6 +662,16 @@ export const useSlashCommandProcessor = ( setCustomDialog(result.component); return { type: 'handled' }; } + case 'workspaces_list': { + addItem( + { + type: MessageType.WORKSPACES_LIST, + workspaces: result.workspaces, + } as any, + Date.now(), + ); + return { type: 'handled' }; + } default: { const unhandled: never = result; throw new Error( diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 2f8e414a83..f2bf69eeb3 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -16,6 +16,7 @@ import { type AgentDefinition, type ApprovalMode, type Kind, + type WorkspaceHubInfo, CoreToolCallStatus, checkExhaustive, } from '@google/gemini-cli-core'; @@ -272,6 +273,11 @@ export type HistoryItemChatList = HistoryItemBase & { chats: ChatDetail[]; }; +export type HistoryItemWorkspacesList = HistoryItemBase & { + type: 'workspaces_list'; + workspaces: WorkspaceHubInfo[]; +}; + export interface ToolDefinition { name: string; displayName: string; @@ -379,6 +385,7 @@ export type HistoryItemWithoutId = | HistoryItemAgentsList | HistoryItemMcpStatus | HistoryItemChatList + | HistoryItemWorkspacesList | HistoryItemThinking | HistoryItemHint; @@ -404,6 +411,7 @@ export enum MessageType { AGENTS_LIST = 'agents_list', MCP_STATUS = 'mcp_status', CHAT_LIST = 'chat_list', + WORKSPACES_LIST = 'workspaces_list', HINT = 'hint', } diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index e361d7ffe0..5895e880cc 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -4,7 +4,10 @@ "outDir": "dist", "jsx": "react-jsx", "lib": ["DOM", "DOM.Iterable", "ES2023"], - "types": ["node", "vitest/globals"] + "types": ["node", "vitest/globals"], + "paths": { + "@google-gemini-cli-core": ["../core/src/index.ts"] + } }, "include": [ "index.ts", diff --git a/packages/core/src/commands/types.ts b/packages/core/src/commands/types.ts index 62bda279af..d02f4b4aaa 100644 --- a/packages/core/src/commands/types.ts +++ b/packages/core/src/commands/types.ts @@ -5,6 +5,8 @@ */ import type { Content, PartListUnion } from '@google/genai'; +import type { WorkspaceHubInfo } from '../services/workspaceHubClient.js'; + /** * The return type for a command action that results in scheduling a tool call. */ @@ -19,6 +21,11 @@ export interface ToolActionReturn { postSubmitPrompt?: PartListUnion; } +export interface WorkspacesListActionReturn { + type: 'workspaces_list'; + workspaces: WorkspaceHubInfo[]; +} + /** * The return type for a command action that results in a simple message * being displayed to the user. @@ -50,6 +57,7 @@ export interface SubmitPromptActionReturn { export type CommandActionReturn = | ToolActionReturn + | WorkspacesListActionReturn | MessageActionReturn | LoadHistoryActionReturn | SubmitPromptActionReturn; diff --git a/packages/core/src/commands/workspace.ts b/packages/core/src/commands/workspace.ts index 9df07e88e6..0f140ed98b 100644 --- a/packages/core/src/commands/workspace.ts +++ b/packages/core/src/commands/workspace.ts @@ -6,7 +6,7 @@ import type { Config } from '../config/config.js'; import { WorkspaceHubClient } from '../services/workspaceHubClient.js'; -import type { MessageActionReturn } from './types.js'; +import type { CommandActionReturn } from './types.js'; function getHubUrl(config: Config): string { if (process.env['GEMINI_WORKSPACE_HUB_URL']) { @@ -26,7 +26,7 @@ function getHubUrl(config: Config): string { export async function listWorkspaces( config: Config, -): Promise { +): Promise { const hubUrl = getHubUrl(config); const client = new WorkspaceHubClient(hubUrl); @@ -41,17 +41,9 @@ export async function listWorkspaces( }; } - let content = 'Active Workspaces:\n'; - content += '------------------------------------------------------------\n'; - for (const ws of workspaces) { - content += `${ws.name.padEnd(20)} | ${ws.status.padEnd(12)} | ${ws.id}\n`; - } - content += '------------------------------------------------------------'; - return { - type: 'message', - messageType: 'info', - content, + type: 'workspaces_list', + workspaces, }; } catch (error: unknown) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -68,7 +60,7 @@ export async function createWorkspace( config: Config, name: string, machineType?: string, -): Promise { +): Promise { const hubUrl = getHubUrl(config); const client = new WorkspaceHubClient(hubUrl); @@ -93,7 +85,7 @@ export async function createWorkspace( export async function deleteWorkspace( config: Config, id: string, -): Promise { +): Promise { const hubUrl = getHubUrl(config); const client = new WorkspaceHubClient(hubUrl); diff --git a/packages/workspace-manager/src/routes/workspaceRoutes.ts b/packages/workspace-manager/src/routes/workspaceRoutes.ts index 7fbb332af5..05fb24b347 100644 --- a/packages/workspace-manager/src/routes/workspaceRoutes.ts +++ b/packages/workspace-manager/src/routes/workspaceRoutes.ts @@ -24,7 +24,7 @@ const CreateWorkspaceSchema = z.object({ router.get('/', async (req, res) => { try { - const authReq = req as AuthenticatedRequest; + const authReq = req as unknown as AuthenticatedRequest; const workspaces = await workspaceService.listWorkspaces(authReq.user.id); res.json(workspaces); } catch (error) { @@ -35,7 +35,7 @@ router.get('/', async (req, res) => { router.post('/', async (req, res) => { try { - const authReq = req as AuthenticatedRequest; + const authReq = req as unknown as AuthenticatedRequest; const validation = CreateWorkspaceSchema.safeParse(req.body); if (!validation.success) { res.status(400).json({ error: validation.error.format() }); @@ -80,7 +80,7 @@ router.post('/', async (req, res) => { router.delete('/:id', async (req, res) => { try { - const authReq = req as AuthenticatedRequest; + const authReq = req as unknown as AuthenticatedRequest; const { id } = req.params; const workspace = await workspaceService.getWorkspace(id); diff --git a/plans/milestone-5-ui-and-advanced.md b/plans/milestone-5-ui-and-advanced.md new file mode 100644 index 0000000000..1dc56d5ad9 --- /dev/null +++ b/plans/milestone-5-ui-and-advanced.md @@ -0,0 +1,32 @@ +# Milestone 5 Sub-plan: UI & Advanced Hub Features + +## 1. Objective +Provide a polished, interactive dashboard for managing workspaces and enhance the Hub with production-grade management features like TTL-based auto-cleanup and expanded multi-tenancy models. + +## 2. Tasks + +### Task 5.1: Workspaces "Ability" (React UI) +Create an interactive dashboard within the `gemini-cli`. +- [ ] Create `packages/cli/src/ui/abilities/workspaces/`. +- [ ] Implement `WorkspacesView` component using Ink. +- [ ] Logic to display a live-updating table of workspaces. +- [ ] Implement interactive actions: "Connect", "Start/Stop", "Delete" within the UI. + +### Task 5.2: Hub Auto-Cleanup (TTL) +Prevent runaway GCP costs by cleaning up idle workspaces. +- [ ] Add `last_connected_at` tracking to `WorkspaceService`. +- [ ] Implement a `/cleanup` endpoint in the Hub. +- [ ] Logic to identify and delete/stop VMs that have been idle past a configurable TTL. + +### Task 5.3: Expanded Multi-Tenancy (Team/Repo) +Enhance the Hub to support shared and automated environments. +- [ ] Implement `Team` mode logic where workspaces can be shared within a Google Group. +- [ ] Implement `Repo` mode primitives to tie workspaces to specific GitHub PRs. + +## 3. Verification & Success Criteria +- **UI:** The user can navigate to the "Workspaces" ability and manage their fleet using keyboard shortcuts or a menu. +- **Cleanup:** A workspace not connected to for > TTL is automatically terminated by the Hub. +- **Tenancy:** Verified isolation and sharing rules in a simulated multi-user environment. + +## 4. Next Steps +- Implement Task 5.1: Scaffold the Workspaces Ability in the CLI. diff --git a/plans/workspaces-implementation.md b/plans/workspaces-implementation.md index 5282b738f2..942069d532 100644 --- a/plans/workspaces-implementation.md +++ b/plans/workspaces-implementation.md @@ -41,10 +41,10 @@ See [Milestone 4 Sub-plan](./milestone-4-sync-and-identity.md) for details. - [ ] Implement secure GitHub PAT injection via `/dev/shm`. ### Milestone 5: UI & Advanced Hub Features (Phase 5) - Polish the developer experience and add enterprise-grade Hub capabilities. - +See [Milestone 5 Sub-plan](./milestone-5-ui-and-advanced.md) for details. - [ ] Implement the "Workspaces Ability" in the CLI (interactive React UI). + - [ ] Implement multi-tenancy models (User, Team, Repo) in the Hub. - [ ] Add auto-cleanup (TTL) and resource monitoring to the Hub.