feat(workspaces): implement Workspaces UI and action primitives

This commit is contained in:
mkorwel
2026-03-19 09:43:39 -07:00
parent 26ce07d89b
commit 309bae18da
12 changed files with 154 additions and 52 deletions
@@ -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';
@@ -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<void | MessageActionReturn> => {
): Promise<CommandActionReturn> => {
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<MessageActionReturn> => {
): Promise<CommandActionReturn> => {
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<MessageActionReturn> => {
): Promise<CommandActionReturn> => {
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<MessageActionReturn> => {
): Promise<CommandActionReturn> => {
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,
};
@@ -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<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'chat_list' && (
<ChatList chats={itemForDisplay.chats} />
)}
{itemForDisplay.type === 'workspaces_list' && (
<WorkspacesList workspaces={itemForDisplay.workspaces} />
)}
</Box>
);
};
@@ -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<WorkspacesListProps> = ({ workspaces }) => {
if (workspaces.length === 0) {
return <Text>No active workspaces found.</Text>;
}
return (
<Box flexDirection="column" marginBottom={1}>
<Text bold underline>Active Remote Workspaces:</Text>
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
{workspaces.map((ws) => {
const isReady = ws.status === 'READY';
const statusColor = isReady ? 'green' : 'yellow';
return (
<Box key={ws.id} flexDirection="column" marginBottom={1}>
<Box>
<Text color="cyan" bold>{ws.name.padEnd(20)}</Text>
<Text> | </Text>
<Text color={statusColor}>{ws.status.padEnd(12)}</Text>
<Text> | </Text>
<Text dimColor>{ws.id}</Text>
</Box>
<Box paddingLeft={2}>
<Text dimColor>Instance: {ws.instance_name} ({ws.zone})</Text>
</Box>
<Box paddingLeft={2}>
<Text dimColor>Project: {ws.project_id}</Text>
</Box>
</Box>
);
})}
</Box>
<Box marginTop={1}>
<Text dimColor italic>Use `/wsr connect {'<name>'}` to teleport into a workspace.</Text>
</Box>
</Box>
);
};
@@ -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(
+8
View File
@@ -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',
}
+4 -1
View File
@@ -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",
+8
View File
@@ -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<HistoryType = unknown> =
| ToolActionReturn
| WorkspacesListActionReturn
| MessageActionReturn
| LoadHistoryActionReturn<HistoryType>
| SubmitPromptActionReturn;
+6 -14
View File
@@ -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<MessageActionReturn> {
): Promise<CommandActionReturn> {
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<MessageActionReturn> {
): Promise<CommandActionReturn> {
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<MessageActionReturn> {
): Promise<CommandActionReturn> {
const hubUrl = getHubUrl(config);
const client = new WorkspaceHubClient(hubUrl);
@@ -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);
+32
View File
@@ -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.
+2 -2
View File
@@ -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.