VS Code companion (#461)

* Init port discovery

* Fmt

* Remove unused

* Fmt

* Simplify

* Container lookup API

* Isolated task details

* Fmt

* Lint and format

* Lint
This commit is contained in:
Louis Knight-Webb
2025-08-13 18:10:19 +01:00
committed by GitHub
parent e970a6eb75
commit 141e1686fd
13 changed files with 319 additions and 68 deletions

View File

@@ -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"
}

View File

@@ -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<Vec<PrInfo>, sqlx::Error> {
let rows = sqlx::query!(
r#"SELECT

View File

@@ -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) {

View File

@@ -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<ContainerQuery>,
State(deployment): State<DeploymentImpl>,
) -> Result<ResponseJson<ApiResponse<ContainerInfo>>, 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<DeploymentImpl> {
Router::new().route("/containers/info", get(get_container_info))
}

View File

@@ -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<Router> {
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))

View File

@@ -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;

View File

@@ -0,0 +1,12 @@
use std::{env, path::PathBuf};
use tokio::fs;
pub async fn write_port_file(port: u16) -> std::io::Result<PathBuf> {
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)
}

View File

@@ -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={<ProjectTasks />}
/>
<Route
path="/projects/:projectId/tasks/:taskId/full"
element={<TaskDetailsPage />}
/>
<Route
path="/projects/:projectId/tasks/:taskId/attempts/:attemptId/full"
element={<TaskDetailsPage />}
/>
<Route
path="/projects/:projectId/tasks/:taskId/attempts/:attemptId"
element={<ProjectTasks />}

View File

@@ -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 (
<div className="border-b">
<div className="px-4 pb-2 flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">
Task Details
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setIsHeaderCollapsed((prev) => !prev)}
className="h-6 w-6 p-0"
>
{isHeaderCollapsed ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronUp className="h-4 w-4" />
)}
</Button>
</div>
{!isHeaderCollapsed && <TaskDetailsToolbar />}
</div>
);
}
export default memo(CollapsibleToolbar);

View File

@@ -15,6 +15,7 @@ interface TaskDetailsHeaderProps {
onClose: () => void;
onEditTask?: (task: TaskWithAttemptStatus) => void;
onDeleteTask?: (taskId: string) => void;
hideCloseButton?: boolean;
}
const statusLabels: Record<TaskStatus, string> = {
@@ -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({
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Close panel</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{!hideCloseButton && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Close panel</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>

View File

@@ -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) */}
<div className={getBackdropClasses()} onClick={onClose} />
{!hideBackdrop && (
<div className={getBackdropClasses()} onClick={onClose} />
)}
{/* Panel */}
<div className={getTaskPanelClasses()}>
<div className={className || getTaskPanelClasses()}>
<div className="flex flex-col h-full">
<TaskDetailsHeader
onClose={onClose}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
/>
{!hideHeader && (
<TaskDetailsHeader
onClose={onClose}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
hideCloseButton={hideBackdrop}
/>
)}
<CollapsibleToolbar />
<TaskDetailsToolbar />
<TabNavigation
activeTab={activeTab}

View File

@@ -78,6 +78,7 @@ function TaskDetailsToolbar() {
);
const { isStopping } = useContext(TaskAttemptStoppingContext);
const location = useLocation();
const { setAttemptData, isAttemptRunning } = useContext(
TaskAttemptDataContext
);
@@ -91,7 +92,6 @@ function TaskDetailsToolbar() {
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
const [selectedProfile, setSelectedProfile] = useState<string | null>(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 (
<>
<div className="px-4 pb-4 border-b">
<div className="p-4 border-b">
{/* Error Display */}
{ui.error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">

View File

@@ -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<TaskWithAttemptStatus | null>(null);
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-background flex items-center justify-center">
<Loader message="Loading task details..." size={32} />
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<div className="text-destructive text-lg mb-4">{error}</div>
<button
onClick={handleClose}
className="text-primary hover:underline"
>
Back to tasks
</button>
</div>
</div>
);
}
if (!task || !project) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<div className="text-muted-foreground text-lg mb-4">
Task not found
</div>
<button
onClick={handleClose}
className="text-primary hover:underline"
>
Back to tasks
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<TaskDetailsPanel
task={task}
projectHasDevScript={!!project.dev_script}
projectId={projectId!}
onClose={handleClose}
onEditTask={handleEditTask}
onDeleteTask={handleDeleteTask}
hideBackdrop={true}
hideHeader={true}
className="w-full h-screen flex flex-col"
/>
</div>
);
}