diff --git a/crates/db/.sqlx/query-233a016d4de730d203f4120f93daaddd10f3047ae17290c82dbbea1aafd064d1.json b/crates/db/.sqlx/query-233a016d4de730d203f4120f93daaddd10f3047ae17290c82dbbea1aafd064d1.json new file mode 100644 index 00000000..4f583c89 --- /dev/null +++ b/crates/db/.sqlx/query-233a016d4de730d203f4120f93daaddd10f3047ae17290c82dbbea1aafd064d1.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT ta.id as \"attempt_id!: Uuid\",\n ta.task_id as \"task_id!: Uuid\",\n t.project_id as \"project_id!: Uuid\"\n FROM task_attempts ta\n JOIN tasks t ON ta.task_id = t.id\n WHERE ta.container_ref = ?", + "describe": { + "columns": [ + { + "name": "attempt_id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "task_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "project_id!: Uuid", + "ordinal": 2, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "233a016d4de730d203f4120f93daaddd10f3047ae17290c82dbbea1aafd064d1" +} diff --git a/crates/db/src/models/task_attempt.rs b/crates/db/src/models/task_attempt.rs index 9354f6d9..3334a44a 100644 --- a/crates/db/src/models/task_attempt.rs +++ b/crates/db/src/models/task_attempt.rs @@ -556,6 +556,26 @@ impl TaskAttempt { Ok(()) } + pub async fn resolve_container_ref( + pool: &SqlitePool, + container_ref: &str, + ) -> Result<(Uuid, Uuid, Uuid), sqlx::Error> { + let result = sqlx::query!( + r#"SELECT ta.id as "attempt_id!: Uuid", + ta.task_id as "task_id!: Uuid", + t.project_id as "project_id!: Uuid" + FROM task_attempts ta + JOIN tasks t ON ta.task_id = t.id + WHERE ta.container_ref = ?"#, + container_ref + ) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + Ok((result.attempt_id, result.task_id, result.project_id)) + } + pub async fn get_open_prs(pool: &SqlitePool) -> Result, sqlx::Error> { let rows = sqlx::query!( r#"SELECT diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 519431e1..f52a71bf 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -5,7 +5,9 @@ use sqlx::Error as SqlxError; use strip_ansi_escapes::strip; use thiserror::Error; use tracing_subscriber::{prelude::*, EnvFilter}; -use utils::{assets::asset_dir, browser::open_browser, sentry::sentry_layer}; +use utils::{ + assets::asset_dir, browser::open_browser, port_file::write_port_file, sentry::sentry_layer, +}; #[derive(Debug, Error)] pub enum VibeKanbanError { @@ -65,6 +67,9 @@ async fn main() -> Result<(), VibeKanbanError> { let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await?; let actual_port = listener.local_addr()?.port(); // get → 53427 (example) + // Write port file for discovery + write_port_file(actual_port).await?; + tracing::info!("Server running on http://{host}:{actual_port}"); if !cfg!(debug_assertions) { diff --git a/crates/server/src/routes/containers.rs b/crates/server/src/routes/containers.rs new file mode 100644 index 00000000..89f8b157 --- /dev/null +++ b/crates/server/src/routes/containers.rs @@ -0,0 +1,54 @@ +use axum::{ + extract::{Query, State}, + response::Json as ResponseJson, + routing::get, + Router, +}; +use db::models::task_attempt::TaskAttempt; +use deployment::Deployment; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utils::response::ApiResponse; +use uuid::Uuid; + +use crate::{error::ApiError, DeploymentImpl}; + +#[derive(Debug, Serialize, TS)] +pub struct ContainerInfo { + pub attempt_id: Uuid, + pub task_id: Uuid, + pub project_id: Uuid, +} + +#[derive(Debug, Deserialize)] +pub struct ContainerQuery { + #[serde(rename = "ref")] + pub container_ref: String, +} + +pub async fn get_container_info( + Query(query): Query, + State(deployment): State, +) -> Result>, ApiError> { + let pool = &deployment.db().pool; + + let (attempt_id, task_id, project_id) = + TaskAttempt::resolve_container_ref(pool, &query.container_ref) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => ApiError::Database(e), + _ => ApiError::Database(e), + })?; + + let container_info = ContainerInfo { + attempt_id, + task_id, + project_id, + }; + + Ok(ResponseJson(ApiResponse::success(container_info))) +} + +pub fn router(_deployment: &DeploymentImpl) -> Router { + Router::new().route("/containers/info", get(get_container_info)) +} diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index e15b2c7e..9ff3962d 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -7,6 +7,7 @@ use crate::DeploymentImpl; pub mod auth; pub mod config; +pub mod containers; pub mod filesystem; // pub mod github; pub mod events; @@ -23,6 +24,7 @@ pub fn router(deployment: DeploymentImpl) -> IntoMakeService { let base_routes = Router::new() .route("/health", get(health::health_check)) .merge(config::router()) + .merge(containers::router(&deployment)) .merge(projects::router(&deployment)) .merge(tasks::router(&deployment)) .merge(task_attempts::router(&deployment)) diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 58186294..cdf9f789 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -8,6 +8,7 @@ pub mod diff; pub mod log_msg; pub mod msg_store; pub mod path; +pub mod port_file; pub mod response; pub mod sentry; pub mod shell; diff --git a/crates/utils/src/port_file.rs b/crates/utils/src/port_file.rs new file mode 100644 index 00000000..6ced4ed4 --- /dev/null +++ b/crates/utils/src/port_file.rs @@ -0,0 +1,12 @@ +use std::{env, path::PathBuf}; + +use tokio::fs; + +pub async fn write_port_file(port: u16) -> std::io::Result { + let dir = env::temp_dir().join("vibe-kanban"); + let path = dir.join("vibe-kanban.port"); + tracing::debug!("Writing port {} to {:?}", port, path); + fs::create_dir_all(&dir).await?; + fs::write(&path, port.to_string()).await?; + Ok(path) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d3f8a3c7..bc1d65a8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; -import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { BrowserRouter, Route, Routes, useLocation } from 'react-router-dom'; import { Navbar } from '@/components/layout/navbar'; import { Projects } from '@/pages/projects'; import { ProjectTasks } from '@/pages/project-tasks'; +import { TaskDetailsPage } from '@/pages/task-details'; import { Settings } from '@/pages/Settings'; import { McpServers } from '@/pages/McpServers'; @@ -22,11 +23,12 @@ const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); function AppContent() { const { config, updateConfig, loading } = useConfig(); + const location = useLocation(); const [showDisclaimer, setShowDisclaimer] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false); const [showPrivacyOptIn, setShowPrivacyOptIn] = useState(false); const [showGitHubLogin, setShowGitHubLogin] = useState(false); - const showNavbar = true; + const showNavbar = !location.pathname.endsWith('/full'); useEffect(() => { if (config) { @@ -161,6 +163,14 @@ function AppContent() { path="/projects/:projectId/tasks" element={} /> + } + /> + } + /> } diff --git a/frontend/src/components/tasks/TaskDetails/CollapsibleToolbar.tsx b/frontend/src/components/tasks/TaskDetails/CollapsibleToolbar.tsx deleted file mode 100644 index 7b7f9458..00000000 --- a/frontend/src/components/tasks/TaskDetails/CollapsibleToolbar.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { memo, useState } from 'react'; -import { Button } from '@/components/ui/button.tsx'; -import { ChevronDown, ChevronUp } from 'lucide-react'; -import TaskDetailsToolbar from '@/components/tasks/TaskDetailsToolbar.tsx'; - -function CollapsibleToolbar() { - const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false); - - return ( -
-
-

- Task Details -

- -
- {!isHeaderCollapsed && } -
- ); -} - -export default memo(CollapsibleToolbar); diff --git a/frontend/src/components/tasks/TaskDetailsHeader.tsx b/frontend/src/components/tasks/TaskDetailsHeader.tsx index 65cf8ca3..d3f689b5 100644 --- a/frontend/src/components/tasks/TaskDetailsHeader.tsx +++ b/frontend/src/components/tasks/TaskDetailsHeader.tsx @@ -15,6 +15,7 @@ interface TaskDetailsHeaderProps { onClose: () => void; onEditTask?: (task: TaskWithAttemptStatus) => void; onDeleteTask?: (taskId: string) => void; + hideCloseButton?: boolean; } const statusLabels: Record = { @@ -46,6 +47,7 @@ function TaskDetailsHeader({ onClose, onEditTask, onDeleteTask, + hideCloseButton = false, }: TaskDetailsHeaderProps) { const { task } = useContext(TaskDetailsContext); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); @@ -102,18 +104,20 @@ function TaskDetailsHeader({ )} - - - - - - -

Close panel

-
-
-
+ {!hideCloseButton && ( + + + + + + +

Close panel

+
+
+
+ )} diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx index 9569b8b3..0c9e94d3 100644 --- a/frontend/src/components/tasks/TaskDetailsPanel.tsx +++ b/frontend/src/components/tasks/TaskDetailsPanel.tsx @@ -13,8 +13,8 @@ import LogsTab from '@/components/tasks/TaskDetails/LogsTab.tsx'; import ProcessesTab from '@/components/tasks/TaskDetails/ProcessesTab.tsx'; import DeleteFileConfirmationDialog from '@/components/tasks/DeleteFileConfirmationDialog.tsx'; import TabNavigation from '@/components/tasks/TaskDetails/TabNavigation.tsx'; -import CollapsibleToolbar from '@/components/tasks/TaskDetails/CollapsibleToolbar.tsx'; import TaskDetailsProvider from '../context/TaskDetailsContextProvider.tsx'; +import TaskDetailsToolbar from './TaskDetailsToolbar.tsx'; interface TaskDetailsPanelProps { task: TaskWithAttemptStatus | null; @@ -24,6 +24,9 @@ interface TaskDetailsPanelProps { onEditTask?: (task: TaskWithAttemptStatus) => void; onDeleteTask?: (taskId: string) => void; isDialogOpen?: boolean; + hideBackdrop?: boolean; + className?: string; + hideHeader?: boolean; } export function TaskDetailsPanel({ @@ -34,6 +37,9 @@ export function TaskDetailsPanel({ onEditTask, onDeleteTask, isDialogOpen = false, + hideBackdrop = false, + className, + hideHeader = false, }: TaskDetailsPanelProps) { const [showEditorDialog, setShowEditorDialog] = useState(false); @@ -74,18 +80,23 @@ export function TaskDetailsPanel({ projectHasDevScript={projectHasDevScript} > {/* Backdrop - only on smaller screens (overlay mode) */} -
+ {!hideBackdrop && ( +
+ )} {/* Panel */} -
+
- + {!hideHeader && ( + + )} - + (null); const [selectedProfile, setSelectedProfile] = useState(null); - const location = useLocation(); const navigate = useNavigate(); const { attemptId: urlAttemptId } = useParams<{ attemptId?: string }>(); const { system, profiles } = useUserSystem(); @@ -214,10 +214,11 @@ function TaskDetailsToolbar() { task && (!urlAttemptId || urlAttemptId !== selectedAttemptToUse.id) ) { - navigate( - `/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttemptToUse.id}`, - { replace: true } - ); + const isFullScreen = location.pathname.endsWith('/full'); + const targetUrl = isFullScreen + ? `/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttemptToUse.id}/full` + : `/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttemptToUse.id}`; + navigate(targetUrl, { replace: true }); } return selectedAttemptToUse; @@ -259,13 +260,14 @@ function TaskDetailsToolbar() { (attempt: TaskAttempt | null) => { setSelectedAttempt(attempt); if (attempt && task) { - navigate( - `/projects/${projectId}/tasks/${task.id}/attempts/${attempt.id}`, - { replace: true } - ); + const isFullScreen = location.pathname.endsWith('/full'); + const targetUrl = isFullScreen + ? `/projects/${projectId}/tasks/${task.id}/attempts/${attempt.id}/full` + : `/projects/${projectId}/tasks/${task.id}/attempts/${attempt.id}`; + navigate(targetUrl, { replace: true }); } }, - [navigate, projectId, task, setSelectedAttempt] + [navigate, projectId, task, setSelectedAttempt, location.pathname] ); // Stub handlers for backward compatibility with CreateAttempt @@ -323,7 +325,7 @@ function TaskDetailsToolbar() { return ( <> -
+
{/* Error Display */} {ui.error && (
diff --git a/frontend/src/pages/task-details.tsx b/frontend/src/pages/task-details.tsx new file mode 100644 index 00000000..4b4ac2fe --- /dev/null +++ b/frontend/src/pages/task-details.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Loader } from '@/components/ui/loader'; +import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel'; +import { projectsApi, tasksApi } from '@/lib/api'; +import type { TaskWithAttemptStatus, Project } from 'shared/types'; + +export function TaskDetailsPage() { + const { projectId, taskId, attemptId } = useParams<{ + projectId: string; + taskId: string; + attemptId?: string; + }>(); + const navigate = useNavigate(); + + const [task, setTask] = useState(null); + const [project, setProject] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const handleClose = () => { + navigate(`/projects/${projectId}/tasks`, { replace: true }); + }; + + const handleEditTask = (task: TaskWithAttemptStatus) => { + // Navigate back to main task page and trigger edit + navigate(`/projects/${projectId}/tasks/${task.id}`); + }; + + const handleDeleteTask = () => { + // Navigate back to main task page after deletion + // navigate(`/projects/${projectId}/tasks`); + }; + + useEffect(() => { + const fetchData = async () => { + if (!projectId || !taskId) { + setError('Missing project or task ID'); + setLoading(false); + return; + } + + try { + setLoading(true); + + // Fetch both project and tasks in parallel + const [projectResult, tasksResult] = await Promise.all([ + projectsApi.getById(projectId), + tasksApi.getAll(projectId), + ]); + + // Find the specific task from the list (to get TaskWithAttemptStatus) + const foundTask = tasksResult.find((t) => t.id === taskId); + + if (!foundTask) { + setError('Task not found'); + setLoading(false); + return; + } + + setProject(projectResult); + setTask(foundTask); + } catch (err) { + console.error('Failed to fetch task details:', err); + setError('Failed to load task details'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [projectId, taskId, attemptId]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+
{error}
+ +
+
+ ); + } + + if (!task || !project) { + return ( +
+
+
+ Task not found +
+ +
+
+ ); + } + + return ( +
+ +
+ ); +}