feat: custom commands (#162)

This commit is contained in:
Carlos Florêncio
2026-02-03 01:56:56 +00:00
committed by GitHub
parent 4eaf467950
commit e474c0c0b5
21 changed files with 586 additions and 224 deletions
@@ -26,7 +26,17 @@ func (c *RepositoryController) ListRepositories(ctx context.Context, req dto.Lis
Total: len(repositories),
}
for _, repository := range repositories {
resp.Repositories = append(resp.Repositories, dto.FromRepository(repository))
repoDTO := dto.FromRepository(repository)
if req.IncludeScripts {
scripts, err := c.service.ListRepositoryScripts(ctx, repository.ID)
if err == nil {
repoDTO.Scripts = make([]dto.RepositoryScriptDTO, 0, len(scripts))
for _, script := range scripts {
repoDTO.Scripts = append(repoDTO.Scripts, dto.FromRepositoryScript(script))
}
}
}
resp.Repositories = append(resp.Repositories, repoDTO)
}
return resp, nil
}
+18 -17
View File
@@ -30,23 +30,24 @@ type WorkspaceDTO struct {
}
type RepositoryDTO struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
SourceType string `json:"source_type"`
LocalPath string `json:"local_path"`
Provider string `json:"provider"`
ProviderRepoID string `json:"provider_repo_id"`
ProviderOwner string `json:"provider_owner"`
ProviderName string `json:"provider_name"`
DefaultBranch string `json:"default_branch"`
WorktreeBranchPrefix string `json:"worktree_branch_prefix"`
PullBeforeWorktree bool `json:"pull_before_worktree"`
SetupScript string `json:"setup_script"`
CleanupScript string `json:"cleanup_script"`
DevScript string `json:"dev_script"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
SourceType string `json:"source_type"`
LocalPath string `json:"local_path"`
Provider string `json:"provider"`
ProviderRepoID string `json:"provider_repo_id"`
ProviderOwner string `json:"provider_owner"`
ProviderName string `json:"provider_name"`
DefaultBranch string `json:"default_branch"`
WorktreeBranchPrefix string `json:"worktree_branch_prefix"`
PullBeforeWorktree bool `json:"pull_before_worktree"`
SetupScript string `json:"setup_script"`
CleanupScript string `json:"cleanup_script"`
DevScript string `json:"dev_script"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Scripts []RepositoryScriptDTO `json:"scripts,omitempty"`
}
type RepositoryScriptDTO struct {
+2 -1
View File
@@ -59,7 +59,8 @@ type DeleteBoardRequest struct {
}
type ListRepositoriesRequest struct {
WorkspaceID string
WorkspaceID string
IncludeScripts bool
}
type GetRepositoryRequest struct {
@@ -67,7 +67,11 @@ func (h *RepositoryHandlers) registerWS(dispatcher *ws.Dispatcher) {
// HTTP handlers
func (h *RepositoryHandlers) httpListRepositories(c *gin.Context) {
resp, err := h.controller.ListRepositories(c.Request.Context(), dto.ListRepositoriesRequest{WorkspaceID: c.Param("id")})
includeScripts := c.Query("include_scripts") == "true"
resp, err := h.controller.ListRepositories(c.Request.Context(), dto.ListRepositoriesRequest{
WorkspaceID: c.Param("id"),
IncludeScripts: includeScripts,
})
if err != nil {
h.logger.Error("failed to list repositories", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list repositories"})
+1 -1
View File
@@ -73,7 +73,7 @@ export default async function Page({ searchParams }: PageProps) {
const [boardList, repositoriesResponse] = await Promise.all([
listBoards(activeWorkspaceId, { cache: 'no-store' }),
listRepositories(activeWorkspaceId, { cache: 'no-store' }).catch(() => ({ repositories: [] })),
listRepositories(activeWorkspaceId, undefined, { cache: 'no-store' }).catch(() => ({ repositories: [] })),
]);
// Use boardIdParam if it exists in this workspace, otherwise fall back
+26 -3
View File
@@ -37,19 +37,26 @@ export default async function SessionPage({
}
task = await fetchTask(session.task_id, { cache: 'no-store' });
const [snapshot, agents, repositories, allSessionsResponse, availableAgentsResponse, workspacesResponse, boardsResponse] = await Promise.all([
const [snapshot, agents, repositoriesResponse, allSessionsResponse, availableAgentsResponse, workspacesResponse, boardsResponse] = await Promise.all([
fetchBoardSnapshot(task.board_id, { cache: 'no-store' }),
listAgents({ cache: 'no-store' }),
listRepositories(task.workspace_id, { cache: 'no-store' }),
listRepositories(task.workspace_id, { includeScripts: true }, { cache: 'no-store' }),
listTaskSessions(session.task_id, { cache: 'no-store' }),
listAvailableAgents({ cache: 'no-store' }).catch(() => ({ agents: [] })),
listWorkspaces({ cache: 'no-store' }).catch(() => ({ workspaces: [] })),
listBoards(task.workspace_id, { cache: 'no-store' }).catch(() => ({ boards: [] })),
]);
const repositories = repositoriesResponse.repositories ?? [];
const allSessions = allSessionsResponse.sessions ?? [session];
const availableAgents = availableAgentsResponse.agents ?? [];
const workspaces = workspacesResponse.workspaces ?? [];
const boards = boardsResponse.boards ?? [];
// Get repository scripts from the repository (already fetched with includeScripts)
const repositoryId = task.repositories?.[0]?.repository_id;
const repository = repositories.find(r => r.id === repositoryId);
const scripts = repository?.scripts ?? [];
let messagesResponse: ListMessagesResponse | null = null;
try {
// Load most recent messages in descending order, then reverse to show oldest-to-newest
@@ -100,7 +107,7 @@ export default async function SessionPage({
},
repositories: {
itemsByWorkspaceId: {
[task.workspace_id]: repositories.repositories,
[task.workspace_id]: repositories,
},
loadingByWorkspaceId: {
[task.workspace_id]: false,
@@ -109,6 +116,21 @@ export default async function SessionPage({
[task.workspace_id]: true,
},
},
repositoryScripts: repositoryId ? {
itemsByRepositoryId: {
[repositoryId]: scripts,
},
loadingByRepositoryId: {
[repositoryId]: false,
},
loadedByRepositoryId: {
[repositoryId]: true,
},
} : {
itemsByRepositoryId: {},
loadingByRepositoryId: {},
loadedByRepositoryId: {},
},
agentProfiles: {
items: agents.agents.flatMap((agent) =>
agent.profiles.map((profile) => ({
@@ -187,6 +209,7 @@ export default async function SessionPage({
task={task}
sessionId={sessionId}
initialRepositories={initialState?.repositories?.itemsByWorkspaceId?.[task?.workspace_id ?? ''] ?? []}
initialScripts={initialState?.repositoryScripts?.itemsByRepositoryId?.[task?.repositories?.[0]?.repository_id ?? ''] ?? []}
defaultLayouts={defaultLayouts}
/>
</>
+2 -1
View File
@@ -9,6 +9,7 @@ export type SessionTab = {
label: string;
icon?: ReactNode;
closable?: boolean;
alwaysShowClose?: boolean;
onClose?: (event: MouseEvent) => void;
className?: string;
};
@@ -63,7 +64,7 @@ export function SessionTabs({
<span
role="button"
tabIndex={-1}
className="ml-0.5 opacity-0 group-hover:opacity-100 transition-opacity hover:text-foreground text-muted-foreground"
className={`ml-0.5 transition-opacity hover:text-foreground text-muted-foreground ${tab.alwaysShowClose ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
onClick={tab.onClose}
>
<svg
+11 -2
View File
@@ -126,8 +126,11 @@ export function ShellTerminal({
);
intersectionObserver.observe(terminalRef.current);
// Reset output tracking when session changes
lastOutputLengthRef.current = 0;
// Reset output tracking when session changes (interactive mode only)
// For read-only mode, we preserve the length set by the initial write above
if (!isReadOnlyMode) {
lastOutputLengthRef.current = 0;
}
return () => {
clearTimeout(initialFitTimeout);
@@ -164,8 +167,14 @@ export function ShellTerminal({
}, [taskId, sessionId, send, isReadOnlyMode]);
// Handle processId changes in read-only mode (clear terminal and reset output)
// Skip on initial mount (processIdRef.current === null) since initialization effect handles it
useEffect(() => {
if (!xtermRef.current || !isReadOnlyMode) return;
if (processIdRef.current === null) {
// Initial mount - just track the processId, don't write (initialization effect handles it)
processIdRef.current = processId ?? null;
return;
}
if (processIdRef.current !== processId) {
processIdRef.current = processId ?? null;
lastOutputLengthRef.current = 0;
-155
View File
@@ -1,155 +0,0 @@
export const CHANGED_FILES = [
{ path: 'apps/web/components/kanban-board.tsx', status: 'M', plus: 12, minus: 4 },
{ path: 'apps/web/components/kanban-card.tsx', status: 'M', plus: 3, minus: 2 },
{ path: 'apps/web/components/task-create-dialog.tsx', status: 'A', plus: 55, minus: 0 },
{ path: 'apps/web/components/kanban-column.tsx', status: 'D', plus: 0, minus: 18 },
];
export const DIFF_SAMPLES: Record<string, { diff: string; newContent: string }> = {
'apps/web/components/kanban-board.tsx': {
diff: [
'diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx',
'index 7b45ad2..9c0f2ee 100644',
'--- a/apps/web/components/kanban-board.tsx',
'+++ b/apps/web/components/kanban-board.tsx',
'@@ -14,6 +14,7 @@ export function KanbanBoard() {',
' const columns = useMemo(() => getColumns(view), [view]);',
' const tasks = useMemo(() => getTasks(), []);',
'+ const hasAlerts = tasks.some((task) => task.priority === "high");',
' return (',
' <div className="kanban-board">',
' <BoardHeader />',
].join('\n'),
newContent: [
'export function KanbanBoard() {',
' const columns = useMemo(() => getColumns(view), [view]);',
' const tasks = useMemo(() => getTasks(), []);',
' const hasAlerts = tasks.some((task) => task.priority === "high");',
' return (',
' <div className="kanban-board">',
' <BoardHeader />',
' </div>',
' );',
'}',
].join('\n'),
},
'apps/web/components/kanban-card.tsx': {
diff: [
'diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx',
'index a14d022..a0cc12f 100644',
'--- a/apps/web/components/kanban-card.tsx',
'+++ b/apps/web/components/kanban-card.tsx',
'@@ -8,7 +8,8 @@ export function KanbanCard({ task }: KanbanCardProps) {',
' return (',
' <Card className="kanban-card">',
'- <h4 className="title">{task.title}</h4>',
'+ <h4 className="title">{task.title}</h4>',
'+ <span className="tag">{task.assignee}</span>',
' <p className="summary">{task.summary}</p>',
' </Card>',
' );',
].join('\n'),
newContent: [
'export function KanbanCard({ task }: KanbanCardProps) {',
' return (',
' <Card className="kanban-card">',
' <h4 className="title">{task.title}</h4>',
' <span className="tag">{task.assignee}</span>',
' <p className="summary">{task.summary}</p>',
' </Card>',
' );',
'}',
].join('\n'),
},
'apps/web/components/task-create-dialog.tsx': {
diff: [
'diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx',
'new file mode 100644',
'index 0000000..6c1a1f0',
'--- /dev/null',
'+++ b/apps/web/components/task-create-dialog.tsx',
'@@ -0,0 +1,6 @@',
'+export function TaskCreateDialog() {',
'+ return (',
'+ <Dialog>',
'+ <DialogContent>Create task</DialogContent>',
'+ </Dialog>',
'+ );',
'+}',
].join('\n'),
newContent: [
'export function TaskCreateDialog() {',
' return (',
' <Dialog>',
' <DialogContent>Create task</DialogContent>',
' </Dialog>',
' );',
'}',
].join('\n'),
},
'apps/web/components/kanban-column.tsx': {
diff: [
'diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx',
'deleted file mode 100644',
'index 9a9b7aa..0000000',
'--- a/apps/web/components/kanban-column.tsx',
'+++ /dev/null',
'@@ -1,5 +0,0 @@',
'-export function KanbanColumn() {',
'- return (',
'- <section className="kanban-column">Column</section>',
'- );',
'-}',
].join('\n'),
newContent: '',
},
};
export const COMMANDS = [
{ id: 'dev', label: 'npm run dev' },
{ id: 'lint', label: 'npm run lint' },
{ id: 'test', label: 'npm run test' },
];
export const FILE_TREE = [
[
'app',
['api', ['hello', ['route.ts']], 'page.tsx', 'layout.tsx', ['blog', ['page.tsx']]],
],
['components', ['ui', 'button.tsx', 'card.tsx'], 'header.tsx', 'footer.tsx'],
['lib', ['util.ts']],
['public', 'favicon.ico', 'vercel.svg'],
'.eslintrc.json',
'.gitignore',
'next.config.js',
'tailwind.config.js',
'package.json',
'README.md',
];
export function buildDiffData(filePath: string) {
const sample = DIFF_SAMPLES[filePath];
if (!sample) {
return {
hunks: [
[
`diff --git a/${filePath} b/${filePath}`,
'index 0000000..0000000 100644',
`--- a/${filePath}`,
`+++ b/${filePath}`,
'@@ -1,1 +1,1 @@',
'-',
'+',
].join('\n'),
],
oldFile: { fileName: filePath, fileLang: 'ts' },
newFile: { fileName: filePath, fileLang: 'ts' },
};
}
return {
hunks: [sample.diff],
oldFile: { fileName: filePath, fileLang: 'ts' },
newFile: { fileName: filePath, fileLang: 'ts' },
};
}
+4 -2
View File
@@ -11,7 +11,7 @@ import { PreviewPanel } from '@/components/task/preview/preview-panel';
import { PreviewController } from '@/components/task/preview/preview-controller';
import { useLayoutStore } from '@/lib/state/layout-store';
import type { OpenFileTab } from '@/lib/types/backend';
import type { Repository } from '@/lib/types/http';
import type { Repository, RepositoryScript } from '@/lib/types/http';
import { useAppStore } from '@/components/state-provider';
const DEFAULT_HORIZONTAL_LAYOUT: Record<string, number> = {
@@ -31,6 +31,7 @@ type TaskLayoutProps = {
boardId: string | null;
sessionId?: string | null;
repository?: Repository | null;
initialScripts?: RepositoryScript[];
defaultLayouts?: Record<string, Layout>;
};
@@ -44,6 +45,7 @@ export const TaskLayout = memo(function TaskLayout({
boardId,
sessionId = null,
repository = null,
initialScripts = [],
defaultLayouts = {},
}: TaskLayoutProps) {
const [selectedDiff, setSelectedDiff] = useState<SelectedDiff | null>(null);
@@ -166,7 +168,7 @@ export const TaskLayout = memo(function TaskLayout({
{layoutState.right ? (
<>
<Panel id="right" minSize="310px" className="min-h-0 min-w-0">
<TaskRightPanel topPanel={topFilesPanel} sessionId={sessionForPreview} />
<TaskRightPanel topPanel={topFilesPanel} sessionId={sessionForPreview} repositoryId={repository?.id ?? null} initialScripts={initialScripts} />
</Panel>
</>
) : null}
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
import { TaskTopBar } from '@/components/task/task-top-bar';
import { TaskLayout } from '@/components/task/task-layout';
import { DebugOverlay } from '@/components/debug-overlay';
import type { Repository, Task } from '@/lib/types/http';
import type { Repository, RepositoryScript, Task } from '@/lib/types/http';
import type { KanbanState } from '@/lib/state/slices';
import { DEBUG_UI } from '@/lib/config';
import { TooltipProvider } from '@kandev/ui/tooltip';
@@ -21,6 +21,7 @@ type TaskPageContentProps = {
task: Task | null;
sessionId?: string | null;
initialRepositories?: Repository[];
initialScripts?: RepositoryScript[];
defaultLayouts?: Record<string, Layout>;
};
@@ -34,6 +35,7 @@ export function TaskPageContent({
task: initialTask,
sessionId = null,
initialRepositories = [],
initialScripts = [],
defaultLayouts = {},
}: TaskPageContentProps) {
const [taskDetails, setTaskDetails] = useState<Task | null>(initialTask);
@@ -252,6 +254,7 @@ export function TaskPageContent({
boardId={task?.board_id ?? null}
sessionId={effectiveSessionId ?? null}
repository={repository ?? null}
initialScripts={initialScripts}
defaultLayouts={defaultLayouts}
/>
</div>
+375 -33
View File
@@ -1,36 +1,41 @@
'use client';
import type { ReactNode, MouseEvent } from 'react';
import { memo, useEffect, useState, useCallback, useMemo } from 'react';
import { memo, useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Badge } from '@kandev/ui/badge';
import { SessionPanel, SessionPanelContent } from '@kandev/ui/pannel-session';
import { Group, Panel } from 'react-resizable-panels';
import { TabsContent } from '@kandev/ui/tabs';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
import { COMMANDS } from '@/components/task/task-data';
import { getLocalStorage, setLocalStorage, getSessionStorage, setSessionStorage } from '@/lib/local-storage';
import { ShellTerminal } from '@/components/task/shell-terminal';
import { useAppStore } from '@/components/state-provider';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import { useLayoutStore } from '@/lib/state/layout-store';
import { stopProcess } from '@/lib/api';
import { stopProcess, startProcess, getSessionProcess } from '@/lib/api';
import { useDefaultLayout } from '@/lib/layout/use-default-layout';
import { SessionTabs, type SessionTab } from '@/components/session-tabs';
import { useRepositoryScripts } from '@/hooks/domains/workspace/use-repository-scripts';
import type { RepositoryScript } from '@/lib/types/http';
type TerminalType = 'commands' | 'dev-server' | 'shell';
type TerminalType = 'commands' | 'dev-server' | 'shell' | 'custom';
type Terminal = {
id: string;
type: TerminalType;
label: string;
processId?: string;
};
type TaskRightPanelProps = {
topPanel: ReactNode;
sessionId?: string | null;
repositoryId?: string | null;
initialScripts?: RepositoryScript[];
};
const DEFAULT_RIGHT_LAYOUT: Record<string, number> = { top: 55, bottom: 45 };
const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null }: TaskRightPanelProps) {
const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null, repositoryId = null, initialScripts = [] }: TaskRightPanelProps) {
const storeApi = useAppStoreApi();
const rightPanelIds = ['top', 'bottom'];
const rightLayoutKey = 'task-layout-right-v2';
const { defaultLayout: rightLayout, onLayoutChanged: onRightLayoutChange } = useDefaultLayout({
@@ -38,9 +43,16 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null
panelIds: rightPanelIds,
baseLayout: DEFAULT_RIGHT_LAYOUT,
});
const [terminals, setTerminals] = useState<Terminal[]>([
{ id: 'shell-1', type: 'shell', label: 'Terminal' },
]);
// Initialize terminals from sessionStorage for persistence across remounts (within browser tab)
const [terminals, setTerminals] = useState<Terminal[]>(() => {
const baseTerminals: Terminal[] = [{ id: 'shell-1', type: 'shell', label: 'Terminal' }];
if (!sessionId) return baseTerminals;
const savedCustomTerminals = getSessionStorage<Terminal[]>(`customTerminals-${sessionId}`, []);
if (savedCustomTerminals.length > 0) {
return [...baseTerminals, ...savedCustomTerminals];
}
return baseTerminals;
});
const [isBottomCollapsed, setIsBottomCollapsed] = useState<boolean>(() =>
getLocalStorage('task-right-panel-collapsed', false)
);
@@ -49,6 +61,60 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null
sessionId ? state.rightPanel.activeTabBySessionId[sessionId] : undefined
);
const setRightPanelActiveTab = useAppStore((state) => state.setRightPanelActiveTab);
// Track if we've restored the tab from localStorage
const tabRestoredRef = useRef(false);
// Track the previous sessionId to detect changes
const prevSessionIdRef = useRef(sessionId);
// Track which process outputs we've already loaded (to prevent duplicates)
const loadedOutputsRef = useRef<Set<string>>(new Set());
// Track which process IDs have been explicitly closed (so sync effect doesn't re-add them)
const closedProcessIdsRef = useRef<Set<string>>(new Set());
// Track if we've initialized closedProcessIdsRef from sessionStorage
const closedIdsInitializedRef = useRef(false);
// Reset terminals and refs when sessionId changes
useEffect(() => {
if (prevSessionIdRef.current === sessionId) return;
prevSessionIdRef.current = sessionId;
// Reset refs
tabRestoredRef.current = false;
loadedOutputsRef.current = new Set();
closedProcessIdsRef.current = new Set();
closedIdsInitializedRef.current = false;
// Reset terminals for new session
const baseTerminals: Terminal[] = [{ id: 'shell-1', type: 'shell', label: 'Terminal' }];
if (!sessionId) {
setTerminals(baseTerminals);
return;
}
const savedCustomTerminals = getSessionStorage<Terminal[]>(`customTerminals-${sessionId}`, []);
if (savedCustomTerminals.length > 0) {
setTerminals([...baseTerminals, ...savedCustomTerminals]);
} else {
setTerminals(baseTerminals);
}
}, [sessionId]);
// Persist active tab to localStorage when it changes
useEffect(() => {
if (!sessionId || !activeTab) return;
setLocalStorage(`rightPanel-tab-${sessionId}`, activeTab);
}, [sessionId, activeTab]);
// Persist custom terminals to sessionStorage when they change
// Skip persisting during session transitions to avoid overwriting the new session's data
useEffect(() => {
if (!sessionId) return;
// Only persist if we're not in a session transition (prevSessionIdRef is updated in reset effect)
if (prevSessionIdRef.current !== sessionId) return;
const customTerminals = terminals.filter(t => t.type === 'custom');
setSessionStorage(`customTerminals-${sessionId}`, customTerminals);
}, [sessionId, terminals]);
const closeLayoutPreview = useLayoutStore((state) => state.closePreview);
const setPreviewOpen = useAppStore((state) => state.setPreviewOpen);
const setPreviewStage = useAppStore((state) => state.setPreviewStage);
@@ -62,6 +128,72 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null
sessionId ? state.previewPanel.openBySessionId[sessionId] ?? false : false
);
// Repository scripts - use initialScripts as fallback for SSR
const { scripts: storeScripts, isLoaded: scriptsLoaded } = useRepositoryScripts(repositoryId);
const scripts = scriptsLoaded ? storeScripts : (initialScripts.length > 0 ? initialScripts : storeScripts);
const hasScripts = scripts.length > 0;
// Restore non-custom tabs from localStorage
useEffect(() => {
if (!sessionId || tabRestoredRef.current) return;
const savedTab = getLocalStorage<string | null>(`rightPanel-tab-${sessionId}`, null);
if (!savedTab) return;
// Skip custom tabs - they're handled in a separate effect after terminals are set up
if (savedTab.startsWith('custom-')) return;
// For 'commands' tab, verify scripts exist (either from SSR or loaded)
if (savedTab === 'commands') {
if (hasScripts) {
setRightPanelActiveTab(sessionId, savedTab);
tabRestoredRef.current = true;
}
return;
}
// For shell tabs, restore immediately
if (savedTab.startsWith('shell-')) {
setRightPanelActiveTab(sessionId, savedTab);
tabRestoredRef.current = true;
}
}, [sessionId, hasScripts, setRightPanelActiveTab]);
// Process state for custom processes
const processState = useAppStore((state) => state.processes);
const appendProcessOutput = useAppStore((state) => state.appendProcessOutput);
const clearProcessOutput = useAppStore((state) => state.clearProcessOutput);
const upsertProcessStatus = useAppStore((state) => state.upsertProcessStatus);
// Get custom processes for this session from store
const customProcesses = useMemo(() => {
if (!sessionId) return [];
const processIds = processState.processIdsBySessionId[sessionId] ?? [];
return processIds
.map(id => processState.processesById[id])
.filter(p => p && p.kind === 'custom');
}, [sessionId, processState.processIdsBySessionId, processState.processesById]);
// Initialize closedProcessIdsRef from sessionStorage on mount
// This prevents re-adding terminals that were closed before page refresh
useEffect(() => {
if (!sessionId || customProcesses.length === 0) return;
if (closedIdsInitializedRef.current) return;
closedIdsInitializedRef.current = true;
const savedCustomTerminals = getSessionStorage<Terminal[]>(`customTerminals-${sessionId}`, []);
const savedProcessIds = new Set(
savedCustomTerminals.map(t => t.processId).filter(Boolean)
);
// Any custom process in store that's not in savedTerminals was closed before refresh
customProcesses.forEach(proc => {
if (!savedProcessIds.has(proc.processId)) {
closedProcessIdsRef.current.add(proc.processId);
}
});
}, [sessionId, customProcesses]);
// Sync dev server terminal with preview state
useEffect(() => {
if (!sessionId) return;
@@ -83,6 +215,87 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null
});
}, [previewOpen, sessionId]);
// Sync custom terminals from store (adds any new processes not already in terminals)
useEffect(() => {
if (!sessionId || customProcesses.length === 0) return;
setTerminals(prev => {
const existingCustomIds = new Set(
prev.filter(t => t.type === 'custom').map(t => t.processId)
);
const newTerminals = customProcesses
.filter(p => !existingCustomIds.has(p.processId) && !closedProcessIdsRef.current.has(p.processId))
.map(p => ({
id: `custom-${p.processId}`,
type: 'custom' as const,
label: p.scriptName ?? 'Script',
processId: p.processId,
}));
if (newTerminals.length === 0) return prev;
return [...prev, ...newTerminals];
});
}, [sessionId, customProcesses]);
// Load output for custom terminals that are no longer running (restore after page refresh)
// Running processes get output via WebSocket streaming - don't fetch from API for those
useEffect(() => {
if (!sessionId) return;
const customTerminals = terminals.filter(t => t.type === 'custom' && t.processId);
customTerminals.forEach(async (terminal) => {
const processId = terminal.processId!;
if (loadedOutputsRef.current.has(processId)) return;
// Check process status - only fetch from API for completed/stopped/failed processes
// Running/starting processes get output via WebSocket streaming
const processInfo = processState.processesById[processId];
const isActive = !processInfo || processInfo.status === 'running' || processInfo.status === 'starting';
if (isActive) return;
// Process is completed/stopped/failed - check if we need to restore output from API
// Get current output from store (don't use as dependency to avoid re-runs on every output change)
const currentOutput = storeApi.getState().processes.outputsByProcessId[processId];
if (currentOutput && currentOutput.length > 0) {
loadedOutputsRef.current.add(processId);
return;
}
loadedOutputsRef.current.add(processId);
try {
const fullProc = await getSessionProcess(sessionId, processId, true);
clearProcessOutput(processId);
if (fullProc.command) {
appendProcessOutput(processId, `$ ${fullProc.command}\n\n`);
}
fullProc.output?.forEach(chunk => {
appendProcessOutput(processId, chunk.data);
});
} catch {
// Remove from loaded set on error so it can be retried
loadedOutputsRef.current.delete(processId);
}
});
}, [sessionId, terminals, processState.processesById, clearProcessOutput, appendProcessOutput, storeApi]);
// Restore saved custom tab after terminals are set up
useEffect(() => {
if (!sessionId || tabRestoredRef.current) return;
const savedTab = getLocalStorage<string | null>(`rightPanel-tab-${sessionId}`, null);
if (!savedTab?.startsWith('custom-')) return;
// Check if this custom tab exists in terminals
const terminalExists = terminals.some(t => t.id === savedTab);
if (terminalExists) {
setRightPanelActiveTab(sessionId, savedTab);
tabRestoredRef.current = true;
}
}, [sessionId, terminals, setRightPanelActiveTab]);
const addTerminal = useCallback(() => {
setTerminals((prev) => {
const shellTerminals = prev.filter((t) => t.type === 'shell');
@@ -102,9 +315,15 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null
const removeTerminal = useCallback(
(id: string) => {
setTerminals((prev) => {
const shellTerminals = prev.filter((t) => t.type === 'shell');
if (shellTerminals.length <= 1) {
return prev; // Keep at least one shell terminal
const terminalToRemove = prev.find((t) => t.id === id);
if (!terminalToRemove) return prev;
// Only enforce "keep at least one shell" for shell terminals
if (terminalToRemove.type === 'shell') {
const shellTerminals = prev.filter((t) => t.type === 'shell');
if (shellTerminals.length <= 1) {
return prev; // Keep at least one shell terminal
}
}
const nextTerminals = prev.filter((t) => t.id !== id);
@@ -170,33 +389,139 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null
[sessionId, devProcessId, terminals, setRightPanelActiveTab, setPreviewOpen, setPreviewStage, closeLayoutPreview]
);
const tabs: SessionTab[] = useMemo(() => {
const commandsTab: SessionTab = { id: 'commands', label: 'Commands' };
const handleRunCommand = useCallback(async (script: RepositoryScript) => {
if (!sessionId) return;
try {
const response = await startProcess(sessionId, {
kind: 'custom',
script_name: script.name
});
if (response?.process) {
const newTerminal: Terminal = {
id: `custom-${response.process.id}`,
type: 'custom',
label: script.name,
processId: response.process.id,
};
// Mark as loaded immediately - WebSocket will stream output, no need to fetch from API
loadedOutputsRef.current.add(response.process.id);
// Display the command for processes that are still starting/running
// Completed/stopped/failed processes will have output restored by the effect (avoids race condition)
const status = response.process.status;
if (response.process.command && (status === 'starting' || status === 'running')) {
clearProcessOutput(response.process.id);
appendProcessOutput(response.process.id, `$ ${response.process.command}\n\n`);
}
setTerminals(prev => {
// Check if terminal already exists
if (prev.some(t => t.id === newTerminal.id)) {
return prev;
}
return [...prev, newTerminal];
});
setRightPanelActiveTab(sessionId, newTerminal.id);
// Update store with process status
upsertProcessStatus({
processId: response.process.id,
sessionId: response.process.session_id,
kind: response.process.kind,
scriptName: response.process.script_name,
status: response.process.status,
command: response.process.command,
workingDir: response.process.working_dir,
exitCode: response.process.exit_code ?? null,
startedAt: response.process.started_at,
updatedAt: response.process.updated_at,
});
}
} catch (error) {
console.error('Failed to start command:', error);
}
}, [sessionId, setRightPanelActiveTab, upsertProcessStatus, clearProcessOutput, appendProcessOutput]);
const handleCloseCustomTab = useCallback(async (
event: MouseEvent,
terminalId: string,
processId?: string
) => {
event.preventDefault();
event.stopPropagation();
// Mark as closed so sync effect doesn't re-add it
if (processId) {
closedProcessIdsRef.current.add(processId);
}
// Remove the terminal immediately
removeTerminal(terminalId);
// Stop process if still running (don't await - do it in background)
if (processId && sessionId) {
const proc = processState.processesById[processId];
if (proc?.status === 'running' || proc?.status === 'starting') {
stopProcess(sessionId, { process_id: processId }).catch((error) => {
console.error('Failed to stop process:', error);
});
}
}
}, [sessionId, processState.processesById, removeTerminal]);
const tabs: SessionTab[] = useMemo(() => {
const allTabs: SessionTab[] = [];
// Only show Commands tab if scripts exist (from SSR or loaded)
if (hasScripts) {
allTabs.push({ id: 'commands', label: 'Commands' });
}
// Add terminal tabs (dev-server, shell, custom)
const terminalTabs: SessionTab[] = terminals.map((terminal) => {
const shellCount = terminals.filter((t) => t.type === 'shell').length;
const isClosable =
terminal.type === 'dev-server' ||
terminal.type === 'custom' ||
(terminal.type === 'shell' && shellCount > 1);
// Check if custom process is running
let icon: React.ReactNode = undefined;
if (terminal.type === 'custom' && terminal.processId) {
const proc = processState.processesById[terminal.processId];
const isRunning = proc?.status === 'running' || proc?.status === 'starting';
if (isRunning) {
icon = <span className="w-1.5 h-1.5 rounded-full bg-green-500" />;
}
}
return {
id: terminal.id,
label: terminal.label,
className:
terminal.type === 'dev-server' || (terminal.type === 'shell' && shellCount > 1)
? 'group flex items-center gap-1 pr-1 cursor-pointer'
: 'cursor-pointer',
closable:
terminal.type === 'dev-server' || (terminal.type === 'shell' && shellCount > 1),
icon,
className: isClosable
? 'group flex items-center gap-1 pr-1 cursor-pointer'
: 'cursor-pointer',
closable: isClosable,
alwaysShowClose: terminal.type === 'custom',
onClose:
terminal.type === 'dev-server'
? handleCloseDevTab
: (e) => {
e.preventDefault();
e.stopPropagation();
removeTerminal(terminal.id);
},
: terminal.type === 'custom'
? (e: MouseEvent) => handleCloseCustomTab(e, terminal.id, terminal.processId)
: (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
removeTerminal(terminal.id);
},
};
});
return [commandsTab, ...terminalTabs];
}, [terminals, handleCloseDevTab, removeTerminal]);
return [...allTabs, ...terminalTabs];
}, [hasScripts, terminals, processState.processesById, handleCloseDevTab, handleCloseCustomTab, removeTerminal]);
if (isBottomCollapsed) {
return (
@@ -258,14 +583,24 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null
<TabsContent value="commands" className="flex-1 min-h-0">
<SessionPanelContent>
<div className="grid gap-2">
{COMMANDS.map((command) => (
{scripts.map((script) => (
<button
key={command.id}
key={script.id}
type="button"
className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm text-left hover:bg-muted"
onClick={() => handleRunCommand(script)}
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm text-left hover:bg-muted cursor-pointer min-w-0"
>
<span>{command.label}</span>
<Badge variant="secondary">Run</Badge>
<span className="flex-1 min-w-0 truncate text-xs">
{script.name}
</span>
<Badge
variant="secondary"
className="shrink-0 font-mono text-xs max-w-[60%] min-w-0"
>
<span className="truncate block">
{script.command}
</span>
</Badge>
</button>
))}
</div>
@@ -276,10 +611,17 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null
<SessionPanelContent className="p-0">
{terminal.type === 'dev-server' ? (
<ShellTerminal
key={devProcessId}
processOutput={devOutput}
processId={devProcessId ?? null}
isStopping={isStoppingDev}
/>
) : terminal.type === 'custom' ? (
<ShellTerminal
key={terminal.processId}
processOutput={processState.outputsByProcessId[terminal.processId ?? ''] ?? ''}
processId={terminal.processId ?? null}
/>
) : (
<ShellTerminal key={sessionId} sessionId={sessionId ?? undefined} />
)}
@@ -34,7 +34,7 @@ export function useRepositories(workspaceId: string | null, enabled = true) {
let cancelled = false;
inFlightRef.current = true;
setRepositoriesLoading(workspaceId, true);
listRepositories(workspaceId, { cache: 'no-store' })
listRepositories(workspaceId, undefined, { cache: 'no-store' })
.then((response) => {
if (cancelled) return;
setRepositories(workspaceId, response.repositories);
@@ -0,0 +1,61 @@
import { useEffect, useRef } from 'react';
import { useAppStore } from '@/components/state-provider';
import type { RepositoryScript } from '@/lib/types/http';
import { listRepositoryScripts } from '@/lib/api';
const EMPTY_SCRIPTS: RepositoryScript[] = [];
export function useRepositoryScripts(repositoryId: string | null, enabled = true) {
const scripts = useAppStore((state) =>
repositoryId
? state.repositoryScripts.itemsByRepositoryId[repositoryId] ?? EMPTY_SCRIPTS
: EMPTY_SCRIPTS
);
const isLoading = useAppStore((state) =>
repositoryId ? state.repositoryScripts.loadingByRepositoryId[repositoryId] ?? false : false
);
const isLoaded = useAppStore((state) =>
repositoryId ? state.repositoryScripts.loadedByRepositoryId[repositoryId] ?? false : false
);
const setRepositoryScripts = useAppStore((state) => state.setRepositoryScripts);
const setRepositoryScriptsLoading = useAppStore((state) => state.setRepositoryScriptsLoading);
const inFlightRef = useRef(false);
useEffect(() => {
if (!enabled || !repositoryId) return;
if (isLoaded && isLoading) {
setRepositoryScriptsLoading(repositoryId, false);
}
}, [enabled, isLoaded, isLoading, setRepositoryScriptsLoading, repositoryId]);
useEffect(() => {
if (!enabled || !repositoryId) return;
if (isLoaded || inFlightRef.current) return;
let cancelled = false;
inFlightRef.current = true;
setRepositoryScriptsLoading(repositoryId, true);
listRepositoryScripts(repositoryId, { cache: 'no-store' })
.then((response) => {
if (cancelled) return;
setRepositoryScripts(repositoryId, response.scripts ?? []);
})
.catch(() => {
if (cancelled) return;
setRepositoryScripts(repositoryId, []);
})
.finally(() => {
inFlightRef.current = false;
if (cancelled) return;
setRepositoryScriptsLoading(repositoryId, false);
});
return () => {
cancelled = true;
inFlightRef.current = false;
};
}, [enabled, isLoaded, setRepositoryScripts, setRepositoryScriptsLoading, repositoryId]);
return { scripts, isLoading, isLoaded };
}
+12 -2
View File
@@ -23,8 +23,18 @@ export async function listWorkspaces(options?: ApiRequestOptions) {
}
// Repository operations
export async function listRepositories(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<ListRepositoriesResponse>(`/api/v1/workspaces/${workspaceId}/repositories`, options);
export async function listRepositories(
workspaceId: string,
params?: { includeScripts?: boolean },
options?: ApiRequestOptions
) {
const searchParams = new URLSearchParams();
if (params?.includeScripts) {
searchParams.set('include_scripts', 'true');
}
const queryString = searchParams.toString();
const url = `/api/v1/workspaces/${workspaceId}/repositories${queryString ? `?${queryString}` : ''}`;
return fetchJson<ListRepositoriesResponse>(url, options);
}
export async function listRepositoryBranches(repositoryId: string, options?: ApiRequestOptions) {
+22
View File
@@ -1,5 +1,27 @@
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
// Session Storage helpers (cleared when browser tab closes)
export function getSessionStorage<T extends JsonValue>(key: string, fallback: T): T {
if (typeof window === 'undefined') return fallback;
try {
const raw = window.sessionStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export function setSessionStorage<T extends JsonValue>(key: string, value: T): void {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(key, JSON.stringify(value));
} catch {
// Ignore write failures (storage full, blocked, etc.)
}
}
// Local Storage helpers (persists across browser sessions)
export function getLocalStorage<T extends JsonValue>(key: string, fallback: T): T {
if (typeof window === 'undefined') return fallback;
try {
+1 -1
View File
@@ -27,7 +27,7 @@ export type { UISlice, UISliceState, UISliceActions } from './ui/types';
// Re-export commonly used types from each domain
export type { KanbanState, BoardState, TaskState } from './kanban/types';
export type { WorkspaceState, RepositoriesState, RepositoryBranchesState } from './workspace/types';
export type { WorkspaceState, RepositoriesState, RepositoryBranchesState, RepositoryScriptsState } from './workspace/types';
export type {
ExecutorsState,
EnvironmentsState,
+10 -1
View File
@@ -1,4 +1,4 @@
import type { Repository, Branch } from '@/lib/types/http';
import type { Repository, Branch, RepositoryScript } from '@/lib/types/http';
export type WorkspaceState = {
items: Array<{
@@ -27,10 +27,17 @@ export type RepositoryBranchesState = {
loadedByRepositoryId: Record<string, boolean>;
};
export type RepositoryScriptsState = {
itemsByRepositoryId: Record<string, RepositoryScript[]>;
loadingByRepositoryId: Record<string, boolean>;
loadedByRepositoryId: Record<string, boolean>;
};
export type WorkspaceSliceState = {
workspaces: WorkspaceState;
repositories: RepositoriesState;
repositoryBranches: RepositoryBranchesState;
repositoryScripts: RepositoryScriptsState;
};
export type WorkspaceSliceActions = {
@@ -40,6 +47,8 @@ export type WorkspaceSliceActions = {
setRepositoriesLoading: (workspaceId: string, loading: boolean) => void;
setRepositoryBranches: (repositoryId: string, branches: Branch[]) => void;
setRepositoryBranchesLoading: (repositoryId: string, loading: boolean) => void;
setRepositoryScripts: (repositoryId: string, scripts: RepositoryScript[]) => void;
setRepositoryScriptsLoading: (repositoryId: string, loading: boolean) => void;
};
export type WorkspaceSlice = WorkspaceSliceState & WorkspaceSliceActions;
@@ -5,6 +5,7 @@ export const defaultWorkspaceState: WorkspaceSliceState = {
workspaces: { items: [], activeId: null },
repositories: { itemsByWorkspaceId: {}, loadingByWorkspaceId: {}, loadedByWorkspaceId: {} },
repositoryBranches: { itemsByRepositoryId: {}, loadingByRepositoryId: {}, loadedByRepositoryId: {} },
repositoryScripts: { itemsByRepositoryId: {}, loadingByRepositoryId: {}, loadedByRepositoryId: {} },
};
export const createWorkspaceSlice: StateCreator<
@@ -49,4 +50,14 @@ export const createWorkspaceSlice: StateCreator<
set((draft) => {
draft.repositoryBranches.loadingByRepositoryId[repositoryId] = loading;
}),
setRepositoryScripts: (repositoryId, scripts) =>
set((draft) => {
draft.repositoryScripts.itemsByRepositoryId[repositoryId] = scripts;
draft.repositoryScripts.loadingByRepositoryId[repositoryId] = false;
draft.repositoryScripts.loadedByRepositoryId[repositoryId] = true;
}),
setRepositoryScriptsLoading: (repositoryId, loading) =>
set((draft) => {
draft.repositoryScripts.loadingByRepositoryId[repositoryId] = loading;
}),
});
+8 -1
View File
@@ -1,7 +1,7 @@
import { createStore } from 'zustand/vanilla';
import { immer } from 'zustand/middleware/immer';
import { hydrateState, type HydrationOptions } from './hydration/hydrator';
import type { Repository, Branch, Message, Turn, TaskSession } from '@/lib/types/http';
import type { Repository, Branch, RepositoryScript, Message, Turn, TaskSession } from '@/lib/types/http';
import {
createKanbanSlice,
createWorkspaceSlice,
@@ -49,6 +49,7 @@ export type {
WorkspaceState,
RepositoriesState,
RepositoryBranchesState,
RepositoryScriptsState,
ExecutorsState,
EnvironmentsState,
SettingsAgentsState,
@@ -103,6 +104,7 @@ export type AppState = {
workspaces: typeof defaultWorkspaceState['workspaces'];
repositories: typeof defaultWorkspaceState['repositories'];
repositoryBranches: typeof defaultWorkspaceState['repositoryBranches'];
repositoryScripts: typeof defaultWorkspaceState['repositoryScripts'];
// Settings slice
executors: typeof defaultSettingsState['executors'];
@@ -164,6 +166,8 @@ export type AppState = {
setRepositoriesLoading: (workspaceId: string, loading: boolean) => void;
setRepositoryBranches: (repositoryId: string, branches: Branch[]) => void;
setRepositoryBranchesLoading: (repositoryId: string, loading: boolean) => void;
setRepositoryScripts: (repositoryId: string, scripts: RepositoryScript[]) => void;
setRepositoryScriptsLoading: (repositoryId: string, loading: boolean) => void;
setSettingsData: (next: Partial<SettingsDataState>) => void;
setEditors: (editors: EditorsState['items']) => void;
setEditorsLoading: (loading: boolean) => void;
@@ -256,6 +260,7 @@ const defaultState = {
workspaces: defaultWorkspaceState.workspaces,
repositories: defaultWorkspaceState.repositories,
repositoryBranches: defaultWorkspaceState.repositoryBranches,
repositoryScripts: defaultWorkspaceState.repositoryScripts,
executors: defaultSettingsState.executors,
environments: defaultSettingsState.environments,
settingsAgents: defaultSettingsState.settingsAgents,
@@ -306,6 +311,7 @@ function mergeInitialState(initialState?: Partial<AppState>): typeof defaultStat
workspaces: { ...defaultState.workspaces, ...initialState.workspaces },
repositories: { ...defaultState.repositories, ...initialState.repositories },
repositoryBranches: { ...defaultState.repositoryBranches, ...initialState.repositoryBranches },
repositoryScripts: { ...defaultState.repositoryScripts, ...initialState.repositoryScripts },
executors: { ...defaultState.executors, ...initialState.executors },
environments: { ...defaultState.environments, ...initialState.environments },
settingsAgents: { ...defaultState.settingsAgents, ...initialState.settingsAgents },
@@ -369,6 +375,7 @@ export function createAppStore(initialState?: Partial<AppState>) {
workspaces: merged.workspaces,
repositories: merged.repositories,
repositoryBranches: merged.repositoryBranches,
repositoryScripts: merged.repositoryScripts,
executors: merged.executors,
environments: merged.environments,
settingsAgents: merged.settingsAgents,
+1
View File
@@ -140,6 +140,7 @@ export type Repository = {
provider_owner: string;
provider_name: string;
default_branch: string;
scripts?: RepositoryScript[];
worktree_branch_prefix: string;
pull_before_worktree: boolean;
setup_script: string;