diff --git a/apps/backend/internal/task/controller/repository.go b/apps/backend/internal/task/controller/repository.go index 3524cd7445..bad6f642e9 100644 --- a/apps/backend/internal/task/controller/repository.go +++ b/apps/backend/internal/task/controller/repository.go @@ -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 } diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 1e7ef5da9a..42599f0a8c 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -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 { diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 2dcd69b01f..c220b798cb 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -59,7 +59,8 @@ type DeleteBoardRequest struct { } type ListRepositoriesRequest struct { - WorkspaceID string + WorkspaceID string + IncludeScripts bool } type GetRepositoryRequest struct { diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index 3b1b96b8b3..9824ea24b5 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -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"}) diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 41e250ca93..0965606220 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -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 diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx index 1c5b158b2e..7917db5fd8 100644 --- a/apps/web/app/s/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -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} /> diff --git a/apps/web/components/session-tabs.tsx b/apps/web/components/session-tabs.tsx index 55834e84d2..f43331209f 100644 --- a/apps/web/components/session-tabs.tsx +++ b/apps/web/components/session-tabs.tsx @@ -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({ { 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; diff --git a/apps/web/components/task/task-data.ts b/apps/web/components/task/task-data.ts deleted file mode 100644 index 65061380e5..0000000000 --- a/apps/web/components/task/task-data.ts +++ /dev/null @@ -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 = { - '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 (', - '
', - ' ', - ].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 (', - '
', - ' ', - '
', - ' );', - '}', - ].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 (', - ' ', - '-

{task.title}

', - '+

{task.title}

', - '+ {task.assignee}', - '

{task.summary}

', - '
', - ' );', - ].join('\n'), - newContent: [ - 'export function KanbanCard({ task }: KanbanCardProps) {', - ' return (', - ' ', - '

{task.title}

', - ' {task.assignee}', - '

{task.summary}

', - '
', - ' );', - '}', - ].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 (', - '+ ', - '+ Create task', - '+ ', - '+ );', - '+}', - ].join('\n'), - newContent: [ - 'export function TaskCreateDialog() {', - ' return (', - ' ', - ' Create task', - ' ', - ' );', - '}', - ].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 (', - '-
Column
', - '- );', - '-}', - ].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' }, - }; -} diff --git a/apps/web/components/task/task-layout.tsx b/apps/web/components/task/task-layout.tsx index 0bc7fdc527..9fc8cc2848 100644 --- a/apps/web/components/task/task-layout.tsx +++ b/apps/web/components/task/task-layout.tsx @@ -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 = { @@ -31,6 +31,7 @@ type TaskLayoutProps = { boardId: string | null; sessionId?: string | null; repository?: Repository | null; + initialScripts?: RepositoryScript[]; defaultLayouts?: Record; }; @@ -44,6 +45,7 @@ export const TaskLayout = memo(function TaskLayout({ boardId, sessionId = null, repository = null, + initialScripts = [], defaultLayouts = {}, }: TaskLayoutProps) { const [selectedDiff, setSelectedDiff] = useState(null); @@ -166,7 +168,7 @@ export const TaskLayout = memo(function TaskLayout({ {layoutState.right ? ( <> - + ) : null} diff --git a/apps/web/components/task/task-page-content.tsx b/apps/web/components/task/task-page-content.tsx index 732e50b5ff..c013211da0 100644 --- a/apps/web/components/task/task-page-content.tsx +++ b/apps/web/components/task/task-page-content.tsx @@ -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; }; @@ -34,6 +35,7 @@ export function TaskPageContent({ task: initialTask, sessionId = null, initialRepositories = [], + initialScripts = [], defaultLayouts = {}, }: TaskPageContentProps) { const [taskDetails, setTaskDetails] = useState(initialTask); @@ -252,6 +254,7 @@ export function TaskPageContent({ boardId={task?.board_id ?? null} sessionId={effectiveSessionId ?? null} repository={repository ?? null} + initialScripts={initialScripts} defaultLayouts={defaultLayouts} />
diff --git a/apps/web/components/task/task-right-panel.tsx b/apps/web/components/task/task-right-panel.tsx index 246c7dd116..73a4c387fd 100644 --- a/apps/web/components/task/task-right-panel.tsx +++ b/apps/web/components/task/task-right-panel.tsx @@ -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 = { 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([ - { id: 'shell-1', type: 'shell', label: 'Terminal' }, - ]); + // Initialize terminals from sessionStorage for persistence across remounts (within browser tab) + const [terminals, setTerminals] = useState(() => { + const baseTerminals: Terminal[] = [{ id: 'shell-1', type: 'shell', label: 'Terminal' }]; + if (!sessionId) return baseTerminals; + const savedCustomTerminals = getSessionStorage(`customTerminals-${sessionId}`, []); + if (savedCustomTerminals.length > 0) { + return [...baseTerminals, ...savedCustomTerminals]; + } + return baseTerminals; + }); const [isBottomCollapsed, setIsBottomCollapsed] = useState(() => 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>(new Set()); + // Track which process IDs have been explicitly closed (so sync effect doesn't re-add them) + const closedProcessIdsRef = useRef>(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(`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(`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(`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(`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 = ; + } + } + 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
- {COMMANDS.map((command) => ( + {scripts.map((script) => ( ))}
@@ -276,10 +611,17 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null {terminal.type === 'dev-server' ? ( + ) : terminal.type === 'custom' ? ( + ) : ( )} diff --git a/apps/web/hooks/domains/workspace/use-repositories.ts b/apps/web/hooks/domains/workspace/use-repositories.ts index 2be7258c6e..9794390f53 100644 --- a/apps/web/hooks/domains/workspace/use-repositories.ts +++ b/apps/web/hooks/domains/workspace/use-repositories.ts @@ -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); diff --git a/apps/web/hooks/domains/workspace/use-repository-scripts.ts b/apps/web/hooks/domains/workspace/use-repository-scripts.ts new file mode 100644 index 0000000000..8656947699 --- /dev/null +++ b/apps/web/hooks/domains/workspace/use-repository-scripts.ts @@ -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 }; +} diff --git a/apps/web/lib/api/domains/workspace-api.ts b/apps/web/lib/api/domains/workspace-api.ts index bdc61c2ccd..14ebca4380 100644 --- a/apps/web/lib/api/domains/workspace-api.ts +++ b/apps/web/lib/api/domains/workspace-api.ts @@ -23,8 +23,18 @@ export async function listWorkspaces(options?: ApiRequestOptions) { } // Repository operations -export async function listRepositories(workspaceId: string, options?: ApiRequestOptions) { - return fetchJson(`/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(url, options); } export async function listRepositoryBranches(repositoryId: string, options?: ApiRequestOptions) { diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index 3ffdc1c066..7f9b0d2997 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -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(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(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(key: string, fallback: T): T { if (typeof window === 'undefined') return fallback; try { diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts index 9fbb44c4ef..a8b5f45ace 100644 --- a/apps/web/lib/state/slices/index.ts +++ b/apps/web/lib/state/slices/index.ts @@ -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, diff --git a/apps/web/lib/state/slices/workspace/types.ts b/apps/web/lib/state/slices/workspace/types.ts index cbdd3747c0..2c023ea8a3 100644 --- a/apps/web/lib/state/slices/workspace/types.ts +++ b/apps/web/lib/state/slices/workspace/types.ts @@ -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; }; +export type RepositoryScriptsState = { + itemsByRepositoryId: Record; + loadingByRepositoryId: Record; + loadedByRepositoryId: Record; +}; + 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; diff --git a/apps/web/lib/state/slices/workspace/workspace-slice.ts b/apps/web/lib/state/slices/workspace/workspace-slice.ts index cf6b81fb9a..c59cfb8e72 100644 --- a/apps/web/lib/state/slices/workspace/workspace-slice.ts +++ b/apps/web/lib/state/slices/workspace/workspace-slice.ts @@ -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; + }), }); diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 0384868a6a..c6b71b2d9c 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -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) => 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): 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) { workspaces: merged.workspaces, repositories: merged.repositories, repositoryBranches: merged.repositoryBranches, + repositoryScripts: merged.repositoryScripts, executors: merged.executors, environments: merged.environments, settingsAgents: merged.settingsAgents, diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 43bb9f3a50..3441424b24 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -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;