diff options
| author | soryu <soryu@soryu.co> | 2026-05-01 18:06:38 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-01 18:06:38 +0100 |
| commit | 80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef (patch) | |
| tree | 5802a9923eab572a98a6d8b21be2fdc56fdf7118 /makima/src | |
| parent | 6d922307223d12f436b229d4c4b29b8835b93b6c (diff) | |
| download | soryu-80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef.tar.gz soryu-80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef.zip | |
feat(doc-mode): unified surface — ephemeral tasks, tmp/, /exec redirect, palette, SWR (#117)
Phases 1-3 of the unified-surface plan, bundled per the user's request.
The directive document/folder UI is now the canonical place to interact
with makima; legacy /exec is subsumed by routing redirects, /contracts is
already hidden in nav, and orphan tasks surface under a top-level tmp/
pseudo-folder. Phase 4 (porting contract-only features) was confirmed
out of scope; Phase 5 (deleting the contracts code) is a follow-up.
## Backend
- New migration `20260501000000_archive_existing_contracts.sql` flips
every legacy contract to `archived` so /contracts is read-only history.
- New endpoint `POST /api/v1/directives/{id}/tasks` creates an ephemeral
task — `directive_id` set, `directive_step_id` NULL, repo/branch
inherited from the directive. Reuses `create_task_for_owner`.
- New endpoint `GET /api/v1/directives/{id}/tasks` lists ephemeral tasks
attached to a directive (drives the per-folder ephemeral group).
- `GET /api/v1/mesh/tasks?orphan=true` returns top-level tasks with no
`directive_id` AND no `parent_task_id` — backs the sidebar's tmp/.
- New repo helpers `list_ephemeral_directive_tasks_for_owner` and
`list_orphan_tasks_for_owner`.
- The existing `mesh_merge` endpoints are reused as-is for ephemeral
task merge (no new merge logic needed).
## Frontend
### Sticky composer + auto-scroll fix (`DocumentTaskStream.tsx`)
- Sticky comment composer pinned to viewport bottom; padding compensates
so the last entry isn't hidden behind it.
- `autoScroll` now resumes when the user scrolls back within 80px of
the bottom (previously stuck off forever after a single scroll-up).
- Floating "↓ Jump to latest" chip when the user has scrolled away.
- Action header strip: explicit Stop / Send / Open-in-task-page +
conditional "Merge to base ↗" button on ephemeral terminal tasks.
- Module-level cache of historical entries by taskId so re-selecting a
task you've viewed renders instantly while a fresh fetch runs.
### Sidebar (`document-directives.tsx`)
- Top-level `tmp/` folder: orphan tasks, polled every 5s.
- Per-directive `tasks/` subfolder now also surfaces ephemeral tasks
(lazily fetched on folder open) with a distinct asterisk-on-terminal
icon (`EphemeralTaskIcon`).
- Inline hover-action chips on each directive folder header: Start /
Pause / PR / +New task. Right-click menu still works as a power-user
fallback.
- "Now executing" amber strip in the editor pane: surfaces the live
orchestrator/completion/running-step task with a one-click jump.
- Inline `+ New task` modal (name + plan); on submit calls
`createDirectiveTask` and navigates into the freshly-spawned task.
- New `EphemeralAwareTaskStream` wrapper passes `ephemeral` and
`status` to `DocumentTaskStream` so the merge button only shows when
the selected task is genuinely an ephemeral spinoff in a terminal
state. Step-spawned tasks merge via the directive's PR completion.
### SWR cache (`useDirectives.ts`)
- Module-level `listCache` and per-id `detailCache` (mirrors the pattern
in `useUserSettings.ts`). Mounting the hook renders the cache value
immediately if present and kicks a background refresh; subscribers see
the new value when it lands. Cuts perceived navigation latency to
near-zero on warm cache hits.
### QuickSwitcher (`QuickSwitcher.tsx`, new)
- IntelliJ-style double-Shift command palette mounted at app root.
- Listens at the document level for two `Shift` keydowns within 300ms
with no other key in between; ignores while focus is in an
input/textarea so capitalising letters doesn't pop the palette.
- Searches across directives + their tasks (orchestrator/completion/
steps/ephemerals) + orphan tmp tasks. Fuzzy matches on title.
- Eagerly loads task details for the first 20 directives on open so
searches don't block on per-directive fetches.
### Routing (`main.tsx` + `exec-redirect.tsx` + `tmp.tsx`)
- New `ExecRedirect` wrapper at `/exec/:id`: when documentMode is on
AND the task has a `directiveId`, replaces the URL with
`/directives/<directiveId>?task=<taskId>`. Otherwise renders the
legacy `MeshPage` as before.
- New `/tmp/:taskId` route renders `DocumentTaskStream` standalone for
orphan tasks, with the masthead and a `tmp / <slug>` breadcrumb.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/db/repository.rs | 65 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 161 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 25 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 4 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 3 |
5 files changed, 256 insertions, 2 deletions
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 27bd47e..b41c74c 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -1189,6 +1189,71 @@ pub async fn list_tasks_for_owner( .await } +/// List ephemeral tasks attached to a directive — tasks with `directive_id` +/// set but no `directive_step_id`. These are the "spinoff" tasks the user +/// created via the directive folder context menu, distinct from +/// step-spawned execution tasks. Hidden tasks excluded. +pub async fn list_ephemeral_directive_tasks_for_owner( + pool: &PgPool, + owner_id: Uuid, + directive_id: Uuid, +) -> Result<Vec<TaskSummary>, sqlx::Error> { + sqlx::query_as::<_, TaskSummary>( + r#" + SELECT + t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + c.status as contract_status, + t.parent_task_id, t.depth, t.name, t.status, t.priority, + t.progress_summary, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, + t.version, t.is_supervisor, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at + FROM tasks t + LEFT JOIN contracts c ON t.contract_id = c.id + WHERE t.owner_id = $1 + AND t.directive_id = $2 + AND t.directive_step_id IS NULL + AND t.parent_task_id IS NULL + AND COALESCE(t.hidden, false) = false + ORDER BY t.created_at DESC + "#, + ) + .bind(owner_id) + .bind(directive_id) + .fetch_all(pool) + .await +} + +/// List "orphan" top-level tasks for an owner — tasks that are NOT attached +/// to a directive and NOT a subtask of another task. These surface in the +/// document-mode sidebar under a top-level `tmp/` folder. Hidden tasks +/// excluded. +pub async fn list_orphan_tasks_for_owner( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<TaskSummary>, sqlx::Error> { + sqlx::query_as::<_, TaskSummary>( + r#" + SELECT + t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + c.status as contract_status, + t.parent_task_id, t.depth, t.name, t.status, t.priority, + t.progress_summary, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, + t.version, t.is_supervisor, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at + FROM tasks t + LEFT JOIN contracts c ON t.contract_id = c.id + WHERE t.owner_id = $1 + AND t.parent_task_id IS NULL + AND t.directive_id IS NULL + AND COALESCE(t.hidden, false) = false + ORDER BY t.priority DESC, t.created_at DESC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + /// List subtasks of a parent task, scoped to owner. pub async fn list_subtasks_for_owner( pool: &PgPool, diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index 7a7aff4..7b13f1c 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -12,6 +12,7 @@ use crate::db::models::{ CleanupResponse, CreateDirectiveRequest, CreateTaskRequest, CreateDirectiveStepRequest, Directive, DirectiveListResponse, DirectiveRevision, DirectiveStep, DirectiveWithSteps, PickUpOrdersResponse, + Task, UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateGoalRequest, CreateDirectiveOrderGroupRequest, DirectiveOrderGroup, DirectiveOrderGroupListResponse, UpdateDirectiveOrderGroupRequest, @@ -2194,3 +2195,163 @@ pub async fn list_directive_revisions( } } } + +// ============================================================================= +// Ephemeral tasks under a directive +// +// A directive can spin up ad-hoc one-off tasks that are NOT part of its DAG. +// They live in the `tasks` table with `directive_id` set and +// `directive_step_id = NULL` — the existing schema already supports this; we +// just expose the create path through a directive-scoped endpoint and +// inherit repo/branch from the parent directive when the caller doesn't +// override. +// ============================================================================= + +/// Request body for creating an ephemeral task under a directive. +#[derive(Debug, serde::Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirectiveTaskRequest { + /// Human-readable name (used for branch/commit names). + pub name: String, + /// Plan / instructions for the Claude Code session. + pub plan: String, + /// Optional override for the directive's repository. + pub repository_url: Option<String>, + /// Optional override for the directive's base branch. + pub base_branch: Option<String>, +} + +/// List the ephemeral tasks (no directive_step_id) attached to a directive. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/tasks", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "List of ephemeral tasks", body = crate::db::models::TaskListResponse), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn list_directive_tasks( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::list_ephemeral_directive_tasks_for_owner(pool, auth.owner_id, id).await { + Ok(tasks) => { + let total = tasks.len() as i64; + Json(crate::db::models::TaskListResponse { tasks, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list ephemeral tasks: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Create an ephemeral task attached to a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/tasks", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = CreateDirectiveTaskRequest, + responses( + (status = 201, description = "Ephemeral task created", body = Task), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn create_directive_task( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CreateDirectiveTaskRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Look up the parent directive so we can inherit its repository/base branch + // when the caller doesn't override them. Also gates the request on + // ownership: if the directive isn't visible to this owner, 404. + let directive = match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(d)) => d, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to load directive for ephemeral task: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(); + } + }; + + let repo_url = req + .repository_url + .or_else(|| directive.repository_url.clone()); + let base_branch = req.base_branch.or_else(|| directive.base_branch.clone()); + + let create_req = CreateTaskRequest { + contract_id: None, + name: req.name, + description: None, + plan: req.plan, + parent_task_id: None, + is_supervisor: false, + priority: 0, + repository_url: repo_url, + base_branch, + target_branch: None, + merge_mode: None, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + checkpoint_sha: None, + branched_from_task_id: None, + conversation_history: None, + supervisor_worktree_task_id: None, + directive_id: Some(directive.id), + // No directive_step_id — this is what makes the task "ephemeral": + // it lives under the directive folder but isn't part of the DAG. + directive_step_id: None, + }; + + match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { + Ok(task) => (StatusCode::CREATED, Json(task)).into_response(), + Err(e) => { + tracing::error!("Failed to create ephemeral directive task: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index 1a5b9c1..ac5652a 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -78,10 +78,24 @@ pub fn extract_auth(state: &SharedState, headers: &HeaderMap) -> AuthSource { // Task Handlers // ============================================================================= -/// List all tasks for the current owner. +/// Query parameters for `list_tasks`. Currently only the `orphan` filter — +/// when set, returns tasks with NO parent_task_id AND NO directive_id, used +/// by the document-mode sidebar's `tmp/` folder. +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListTasksQuery { + #[serde(default)] + pub orphan: bool, +} + +/// List all tasks for the current owner. Pass `?orphan=true` to restrict to +/// top-level tasks with no directive attachment. #[utoipa::path( get, path = "/api/v1/mesh/tasks", + params( + ("orphan" = Option<bool>, Query, description = "Filter to tasks with no directive_id and no parent_task_id"), + ), responses( (status = 200, description = "List of tasks", body = TaskListResponse), (status = 401, description = "Unauthorized", body = ApiError), @@ -97,6 +111,7 @@ pub fn extract_auth(state: &SharedState, headers: &HeaderMap) -> AuthSource { pub async fn list_tasks( State(state): State<SharedState>, Authenticated(auth): Authenticated, + axum::extract::Query(query): axum::extract::Query<ListTasksQuery>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( @@ -106,7 +121,13 @@ pub async fn list_tasks( .into_response(); }; - match repository::list_tasks_for_owner(pool, auth.owner_id).await { + let result = if query.orphan { + repository::list_orphan_tasks_for_owner(pool, auth.owner_id).await + } else { + repository::list_tasks_for_owner(pool, auth.owner_id).await + }; + + match result { Ok(tasks) => { let total = tasks.len() as i64; Json(TaskListResponse { tasks, total }).into_response() diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index c577904..efae901 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -255,6 +255,10 @@ pub fn make_router(state: SharedState) -> Router { .route("/directives/{id}/goal", put(directives::update_goal)) .route("/directives/{id}/revisions", get(directives::list_directive_revisions)) .route("/directives/{id}/new-draft", post(directives::new_directive_draft)) + .route( + "/directives/{id}/tasks", + get(directives::list_directive_tasks).post(directives::create_directive_task), + ) .route("/directives/{id}/cleanup", post(directives::cleanup_directive)) .route("/directives/{id}/create-pr", post(directives::create_pr)) .route("/directives/{id}/pick-up-orders", post(directives::pick_up_orders)) diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index e6d4547..7a4b004 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -132,6 +132,8 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directives::update_goal, directives::list_directive_revisions, directives::new_directive_draft, + directives::create_directive_task, + directives::list_directive_tasks, directives::cleanup_directive, directives::create_pr, // Order endpoints @@ -237,6 +239,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage DirectiveListResponse, DirectiveRevision, crate::server::handlers::directives::DirectiveRevisionListResponse, + crate::server::handlers::directives::CreateDirectiveTaskRequest, CreateDirectiveRequest, UpdateDirectiveRequest, UpdateGoalRequest, |
