Files
FabledScribe/frontend/src/views/TaskEditorView.vue
T
bvandeusenandClaude Opus 5 e2e64b94c0 feat(tasks): task_kind gains 'spike' — the investigation, not the change (#3099, milestone 312 step 5)
A spike is a shape the other kinds cannot hold. `work` ships a change;
`issue` fixes something broken. A spike is time-boxed and its output is
KNOWLEDGE — it succeeds by producing an answer, and nothing ships at the end
of it. Filing one as `work` makes a finished investigation look like an
abandoned change, which is why the distinction earns a value rather than a
convention.

It is also the record a failed check asks for. This milestone gave rules a
verify_with; when one fails the rule is wrong, and the next move is often to
go and find out what replaced it. notes.arose_from_id already exists (0065),
so constraint -> spike provenance needed no schema at all — only a docstring
saying it is there.

Rule 36: the value and the widened CHECK land in the same migration, DROP
then ADD, exactly as 0065 did for 'issue'. The two whitelists live in one
tuple each so upgrade and downgrade cannot disagree about what the list was
on either side. The downgrade demotes existing spikes to 'work' first —
lossy, deliberately, because the alternative is a downgrade that fails on
real data, and one that says what it did beats one that cannot run.

'plan' stays whitelisted though retired: historical plan-tasks carry it, and
a row that cannot be rewritten cannot be edited, restored or migrated.

The integration test asserts both halves. A test that only proved 'spike' is
accepted would pass just as happily against a table whose CHECK had been
dropped and never re-added — which is the other way rule 36's failure
happens — so an unknown kind is asserted to still raise.

Not in scope, deliberately: any special lifecycle, time-box enforcement, or
gating relationship. It is a kind, not a workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 13:43:57 -04:00

1025 lines
35 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, computed, nextTick, onUnmounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAssist } from "@/composables/useAssist";
import { useAutoSave } from "@/composables/useAutoSave";
import { useEditorGuards } from "@/composables/useEditorGuards";
import { useTagSuggestions } from "@/composables/useTagSuggestions";
import { useFloatingAssist } from "@/composables/useFloatingAssist";
import { apiPost, apiGet, apiPatch } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { TaskKind } from "@/types/note";
import { useSystemsStore } from "@/stores/systems";
import type { System } from "@/api/systems";
import type { Note } from "@/types/note";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
import WordCount from "@/components/WordCount.vue";
import TagInput from "@/components/TagInput.vue";
import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue";
import TaskLogSection from "@/components/TaskLogSection.vue";
import DiffView from "@/components/DiffView.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import VersionHistorySection from "@/components/VersionHistorySection.vue";
import RecurrenceEditor from "@/components/RecurrenceEditor.vue";
import PlanRulesPanel from "@/components/rules/PlanRulesPanel.vue";
import { Trash2 } from "lucide-vue-next";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
const systemsStore = useSystemsStore();
const notesStore = useNotesStore();
const toast = useToastStore();
const title = ref("");
const body = ref("");
const description = ref("");
const tags = ref<string[]>([]);
const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none");
const kind = ref<TaskKind>("work");
const systemIds = ref<number[]>([]);
const dueDate = ref("");
const projectId = ref<number | null>(null);
const milestoneId = ref<number | null>(null);
const parentId = ref<number | null>(null);
const parentTitle = ref("");
const startedAt = ref<string | null>(null);
const completedAt = ref<string | null>(null);
const recurrenceRule = ref<Record<string, unknown> | null>(null);
const parentSearchQuery = ref("");
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
const parentSearchLoading = ref(false);
const showParentDropdown = ref(false);
let parentSearchTimer: ReturnType<typeof setTimeout> | null = null;
const dirty = ref(false);
const saving = ref(false);
// Sub-tasks
interface SubTask { id: number; title: string; status: string }
const subTasks = ref<SubTask[]>([]);
const subTasksLoading = ref(false);
const addingSubTask = ref(false);
const newSubTaskTitle = ref("");
async function loadSubTasks() {
if (!taskId.value) return;
subTasksLoading.value = true;
try {
const data = await apiGet<{ notes: SubTask[] }>(
`/api/notes?parent_id=${taskId.value}&is_task=true&limit=100`
);
subTasks.value = data.notes;
} catch {
// silent
} finally {
subTasksLoading.value = false;
}
}
async function createSubTask() {
const title = newSubTaskTitle.value.trim();
if (!title || !taskId.value) return;
try {
const created = await apiPost<SubTask>("/api/notes", {
title,
status: "todo",
priority: "none",
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: taskId.value,
});
subTasks.value = [...subTasks.value, created];
newSubTaskTitle.value = "";
addingSubTask.value = false;
} catch {
toast.show("Failed to create sub-task", "error");
}
}
async function toggleSubTask(sub: SubTask) {
const newStatus = sub.status === "done" ? "todo" : "done";
try {
await apiPatch(`/api/notes/${sub.id}`, { status: newStatus });
sub.status = newStatus;
} catch {
toast.show("Failed to update sub-task", "error");
}
}
const showPreview = ref(false);
const sidebarOpen = ref(true);
// reconsolidate / isBodyAutoMaintained removed in Phase 8 — the curator
// that auto-maintained task bodies is gone, so the body editor is now
// always user-controlled.
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const titleRef = ref<HTMLInputElement | null>(null);
const tiptapEditor = computed<Editor | null>(() => {
return (editorRef.value?.editor as Editor | undefined) ?? null;
});
const taskId = computed(() =>
route.params.id ? Number(route.params.id) : null
);
const isEditing = computed(() => taskId.value !== null);
const renderedPreview = computed(() => renderMarkdown(body.value));
// AI Assist
const assist = useAssist(body, taskId, projectId);
// Scope selector — matches note editor pattern
const scopeOptions = computed(() => {
const opts: Array<{ value: string; label: string }> = [
{ value: "__document__", label: "Whole document" },
];
for (const section of assist.sections.value) {
opts.push({
value: String(assist.sections.value.indexOf(section)),
label: section.heading || "(preamble)",
});
}
return opts;
});
const scopeSelectValue = computed({
get() {
if (assist.scopeMode.value === "document") return "__document__";
if (assist.selectedSection.value) {
const idx = assist.sections.value.indexOf(assist.selectedSection.value);
return idx >= 0 ? String(idx) : "__document__";
}
return "__document__";
},
set(val: string) {
if (val === "__document__") {
assist.scopeMode.value = "document";
assist.selectedSection.value = null;
} else {
const idx = Number(val);
const section = assist.sections.value[idx];
if (section) {
assist.scopeMode.value = "section";
assist.selectSection(section);
}
}
},
});
// Floating inline assist button
const instructionRef = ref<HTMLTextAreaElement | null>(null);
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
({ start, end }) => {
assist.scopeMode.value = "section";
assist.selectTextRange(start, end);
nextTick(() => instructionRef.value?.focus());
}
);
function handleAssistAccept() {
const newBody = assist.accept();
body.value = newBody;
markDirty();
toast.show("Task updated");
}
onUnmounted(() => assist.clearSelection());
// Tag suggestions
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
useTagSuggestions(title, body, tags, markDirty);
let savedTitle = "";
let savedBody = "";
let savedDescription = "";
let savedTags: string[] = [];
let savedStatus: TaskStatus = "todo";
let savedPriority: TaskPriority = "none";
let savedDueDate = "";
let savedProjectId: number | null = null;
let savedMilestoneId: number | null = null;
let savedParentId: number | null = null;
let savedKind: TaskKind = "work";
let savedSystemIds: number[] = [];
function markDirty() {
dirty.value =
title.value !== savedTitle ||
body.value !== savedBody ||
description.value !== savedDescription ||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
status.value !== savedStatus ||
priority.value !== savedPriority ||
dueDate.value !== savedDueDate ||
projectId.value !== savedProjectId ||
milestoneId.value !== savedMilestoneId ||
parentId.value !== savedParentId ||
kind.value !== savedKind ||
JSON.stringify(systemIds.value) !== JSON.stringify(savedSystemIds);
}
const projectSystems = computed<System[]>(() =>
projectId.value ? (systemsStore.systemsByProject[projectId.value] ?? []) : [],
);
async function loadSystems() {
if (!projectId.value) return;
try {
await systemsStore.fetchSystems(projectId.value);
} catch {
/* non-fatal — the systems picker just won't populate */
}
}
function onBodyUpdate(newVal: string) {
body.value = newVal;
markDirty();
}
function onParentSearchInput() {
if (parentSearchTimer) clearTimeout(parentSearchTimer);
if (!parentSearchQuery.value.trim()) {
parentSearchResults.value = [];
showParentDropdown.value = false;
return;
}
parentSearchTimer = setTimeout(async () => {
const q = parentSearchQuery.value.trim();
if (!q) return;
parentSearchLoading.value = true;
showParentDropdown.value = true;
try {
const data = await apiGet<{ notes: Array<{ id: number; title: string }> }>(
`/api/notes?q=${encodeURIComponent(q)}&type=task&limit=8`
);
parentSearchResults.value = data.notes.filter((t) =>
!taskId.value || t.id !== taskId.value
);
} catch {
parentSearchResults.value = [];
} finally {
parentSearchLoading.value = false;
}
}, 250);
}
function selectParentTask(task: { id: number; title: string }) {
parentId.value = task.id;
parentTitle.value = task.title;
parentSearchQuery.value = task.title;
showParentDropdown.value = false;
markDirty();
}
function onParentFocus() {
if (parentSearchQuery.value) showParentDropdown.value = true;
}
function hideParentDropdown() {
setTimeout(() => { showParentDropdown.value = false; }, 200);
}
function clearParentTask() {
parentId.value = null;
parentTitle.value = "";
parentSearchQuery.value = "";
parentSearchResults.value = [];
showParentDropdown.value = false;
markDirty();
}
onMounted(async () => {
if (taskId.value) {
await store.fetchTask(taskId.value);
if (store.currentTask) {
title.value = store.currentTask.title;
body.value = store.currentTask.body;
description.value = store.currentTask.description ?? "";
tags.value = [...(store.currentTask.tags || [])];
status.value = store.currentTask.status as TaskStatus;
priority.value = store.currentTask.priority as TaskPriority;
dueDate.value = store.currentTask.due_date || "";
const taskRec = store.currentTask as Record<string, unknown>;
projectId.value = (taskRec.project_id as number | null) ?? null;
milestoneId.value = (taskRec.milestone_id as number | null) ?? null;
kind.value = (taskRec.task_kind as TaskKind) ?? "work";
systemIds.value = ((taskRec.systems as Array<{ id: number }> | undefined) ?? []).map((s) => s.id);
parentId.value = (taskRec.parent_id as number | null) ?? null;
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
parentSearchQuery.value = parentTitle.value;
const noteTask = store.currentTask as unknown as Note;
startedAt.value = noteTask.started_at ?? null;
completedAt.value = noteTask.completed_at ?? null;
recurrenceRule.value = noteTask.recurrence_rule ?? null;
savedTitle = title.value;
savedBody = body.value;
savedDescription = description.value;
savedTags = [...tags.value];
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
savedKind = kind.value;
savedSystemIds = [...systemIds.value];
// Start in preview mode only if the task already has body content
showPreview.value = body.value.trim().length > 0;
}
loadSubTasks();
} else {
// Pre-fill from query params when creating a new task
if (route.query.projectId) projectId.value = Number(route.query.projectId);
if (route.query.milestoneId) milestoneId.value = Number(route.query.milestoneId);
if (route.query.parentId) parentId.value = Number(route.query.parentId);
}
loadSystems();
});
async function save() {
if (saving.value) return;
saving.value = true;
try {
const data = {
title: title.value,
body: body.value,
description: description.value,
tags: tags.value,
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: parentId.value,
recurrence_rule: recurrenceRule.value,
kind: kind.value,
system_ids: systemIds.value,
};
if (isEditing.value) {
await store.updateTask(taskId.value!, data);
savedTitle = title.value;
savedBody = body.value;
savedDescription = description.value;
savedTags = [...tags.value];
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
savedKind = kind.value;
savedSystemIds = [...systemIds.value];
dirty.value = false;
toast.show("Task saved");
router.push(`/tasks/${taskId.value}`);
} else {
const task = await store.createTask(data);
dirty.value = false;
toast.show("Task created");
router.push(`/tasks/${task.id}`);
}
} catch {
toast.show("Failed to save task", "error");
} finally {
saving.value = false;
}
}
const showDeleteConfirm = ref(false);
function remove() {
if (taskId.value) {
showDeleteConfirm.value = true;
}
}
async function confirmDelete() {
showDeleteConfirm.value = false;
if (!taskId.value) return;
try {
await store.deleteTask(taskId.value);
dirty.value = false;
toast.show("Task deleted");
router.push(projectId.value ? `/projects/${projectId.value}` : "/");
} catch {
toast.show("Failed to delete task", "error");
}
}
// Auto-save every 5 minutes when editing an existing task
async function doAutoSave() {
if (!isEditing.value || saving.value) return;
saving.value = true;
try {
await store.updateTask(taskId.value!, {
title: title.value,
body: body.value,
description: description.value,
tags: tags.value,
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: parentId.value,
recurrence_rule: recurrenceRule.value,
} as Record<string, unknown>);
savedTitle = title.value;
savedBody = body.value;
savedDescription = description.value;
savedTags = [...tags.value];
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
dirty.value = false;
toast.show("Auto-saved");
} catch {
// Silent — user can still save manually
} finally {
saving.value = false;
}
}
useAutoSave(dirty, saving, doAutoSave);
useEditorGuards(dirty, save);
</script>
<template>
<main class="editor-page task-editor-page">
<div class="editor-header">
<div class="toolbar">
<router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back"> {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">
<Trash2 :size="16" /> Delete
</button>
<WordCount :body="body" />
</div>
<input
v-model="title"
type="text"
placeholder="Task title"
ref="titleRef"
class="title-input"
@input="markDirty"
@keydown.ctrl.s.prevent="save"
@keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()"
/>
<div class="task-goal">
<label for="task-description" class="task-goal-label">Goal</label>
<textarea
id="task-description"
v-model="description"
placeholder="What are we trying to do here? (read-only context for the auto-summary)"
rows="2"
class="task-goal-input"
@input="markDirty"
></textarea>
</div>
</div><!-- /editor-header: title + goal -->
<!-- Two-column body: main (editor+log) | sidebar (metadata) -->
<div class="task-body">
<!-- ── Main column ─────────────────────────────────────────── -->
<div class="task-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
<!-- Write / Preview tabs + toolbar sit above the editor. -->
<div class="body-tabs-row">
<div class="editor-tabs">
<button
:class="['tab', { active: !showPreview }]"
@click="showPreview = false"
>Write</button>
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
</div>
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
</div>
<!-- Streaming preview -->
<template v-if="assist.state.value === 'streaming'">
<div class="stream-label">Generating...</div>
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
</template>
<!-- Review: full-document diff -->
<template v-else-if="assist.state.value === 'review'">
<DiffView :diff="assist.diff.value" class="main-diff" />
</template>
<template v-else>
<div
v-show="!showPreview"
class="body-editor-wrap"
>
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Describe this task..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
@escape="titleRef?.focus()"
/>
</div>
<div
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
/>
</template>
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
<!-- Work log -->
<TaskLogSection v-if="taskId" :task-id="taskId" class="body-log" />
<!-- Applicable rules (plan tasks only) -->
<PlanRulesPanel
v-if="store.currentTask?.task_kind === 'plan' && store.currentTask?.project_id"
:project-id="store.currentTask.project_id"
/>
</div>
<!-- ── Sidebar ──────────────────────────────────────────────── -->
<aside class="task-sidebar">
<!-- Mobile accordion toggle -->
<button class="sidebar-toggle" @click="sidebarOpen = !sidebarOpen">
Details {{ sidebarOpen ? '▴' : '▾' }}
</button>
<div :class="['sidebar-content', { 'sidebar-open': sidebarOpen }]">
<!-- Status / Priority / Due date -->
<div class="sb-field">
<label class="sb-label">Status</label>
<select v-model="status" @change="markDirty" class="sb-select">
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="sb-field">
<label class="sb-label">Kind</label>
<select v-model="kind" @change="markDirty" class="sb-select">
<option value="work">Work</option>
<option value="issue">Issue</option>
<option value="spike">Spike</option>
<!-- 'plan' is retired (plans are milestones via start_planning);
offered only so legacy plan-tasks display their kind. -->
<option v-if="kind === 'plan'" value="plan">Plan (legacy)</option>
</select>
</div>
<div v-if="startedAt || completedAt" class="sb-timestamps">
<div v-if="startedAt" class="sb-timestamp">
<span class="sb-ts-label">Started</span>
<span class="sb-ts-value">{{ new Date(startedAt).toLocaleString() }}</span>
</div>
<div v-if="completedAt" class="sb-timestamp">
<span class="sb-ts-label">Completed</span>
<span class="sb-ts-value">{{ new Date(completedAt).toLocaleString() }}</span>
</div>
</div>
<div class="sb-field">
<label class="sb-label">Priority</label>
<select v-model="priority" @change="markDirty" class="sb-select">
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="sb-field">
<label class="sb-label">Due Date</label>
<input v-model="dueDate" type="date" class="sb-input" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Recurrence</label>
<RecurrenceEditor v-model="recurrenceRule" @update:modelValue="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Project</label>
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Milestone</label>
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
</div>
<div v-if="projectId" class="sb-field">
<label class="sb-label">Systems</label>
<div v-if="projectSystems.length" class="sb-systems">
<label v-for="s in projectSystems" :key="s.id" class="sb-system-opt">
<input type="checkbox" :value="s.id" v-model="systemIds" @change="markDirty" />
<span>{{ s.name }}</span>
</label>
</div>
<p v-else class="sb-systems-empty">No systems in this project yet.</p>
</div>
<!-- Parent task -->
<div class="sb-field">
<label class="sb-label">Parent Task</label>
<div class="parent-search-wrapper">
<div class="parent-input-row">
<input
v-model="parentSearchQuery"
type="text"
class="sb-input"
placeholder="Search tasks..."
@input="onParentSearchInput"
@focus="onParentFocus"
@blur="hideParentDropdown"
/>
<button v-if="parentId" class="btn-text btn-clear-parent" @click="clearParentTask" title="Clear">&times;</button>
</div>
<div v-if="showParentDropdown" class="parent-dropdown">
<div v-if="parentSearchLoading" class="parent-dropdown-item parent-empty">Searching...</div>
<div
v-for="task in parentSearchResults"
:key="task.id"
class="parent-dropdown-item"
@mousedown.prevent="selectParentTask(task)"
>{{ task.title || "Untitled" }}</div>
<div v-if="!parentSearchLoading && parentSearchResults.length === 0" class="parent-dropdown-item parent-empty">No tasks found</div>
</div>
</div>
</div>
<div class="sb-divider"></div>
<!-- Sub-tasks -->
<div v-if="isEditing" class="subtasks-section">
<div class="subtasks-header">
<span class="subtasks-label">Sub-tasks</span>
<button class="btn-text" @click="addingSubTask = !addingSubTask">+ Add</button>
</div>
<div v-if="subTasksLoading" class="subtasks-loading">Loading...</div>
<template v-else>
<div v-for="sub in subTasks" :key="sub.id" class="subtask-row">
<input type="checkbox" :checked="sub.status === 'done'" @change="toggleSubTask(sub)" class="subtask-checkbox" />
<router-link :to="`/tasks/${sub.id}`" :class="['subtask-title', { done: sub.status === 'done' }]">
{{ sub.title || "Untitled" }}
</router-link>
</div>
<p v-if="!subTasks.length && !addingSubTask" class="subtasks-empty">No sub-tasks yet.</p>
<div v-if="addingSubTask" class="subtask-add-row">
<input
v-model="newSubTaskTitle"
class="subtask-input"
placeholder="Sub-task title"
@keydown.enter="createSubTask"
@keydown.escape="addingSubTask = false; newSubTaskTitle = ''"
autofocus
/>
<button class="btn-primary btn-compact" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
<button class="btn-ghost btn-compact" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
</div>
</template>
</div>
<div class="sb-divider"></div>
<!-- Tags -->
<div class="sb-field">
<label class="sb-label">Tags</label>
<TagInput
v-model="tags"
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
@update:modelValue="markDirty"
/>
</div>
<div class="tag-suggest-row">
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
</button>
<template v-if="suggestedTags.length > 0">
<button
v-for="tag in suggestedTags"
:key="tag"
:class="['tag-pill', { applied: appliedTags.has(tag) }]"
:disabled="appliedTags.has(tag)"
@click="applyTagSuggestion(tag)"
>
#{{ tag }}
<span v-if="appliedTags.has(tag)" class="tag-check">&#10003;</span>
</button>
<button class="btn-dismiss-tags" aria-label="Dismiss tag suggestions" @click="dismissTagSuggestions">&times;</button>
</template>
</div>
<div class="sb-divider"></div>
<!-- Writing Assistant -->
<div class="assist-section">
<div class="assist-section-title"> Writing Assistant</div>
<div class="sb-field">
<label class="sb-label">Scope</label>
<select v-model="scopeSelectValue" class="sb-select" :disabled="assist.state.value === 'streaming'">
<option v-for="opt in scopeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<template v-if="assist.state.value === 'idle'">
<textarea
ref="instructionRef"
v-model="assist.instruction.value"
placeholder="What should I do?"
class="assist-instruction"
rows="3"
@keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()"
></textarea>
<div class="assist-input-actions">
<button class="btn-generate" @click="assist.submit()" :disabled="!assist.canSubmit.value">Generate</button>
<button class="btn-proofread" @click="assist.proofread()">Proofread</button>
</div>
</template>
<template v-else-if="assist.state.value === 'streaming'">
<div class="assist-active-hint">Generating see main area</div>
<button class="btn-clear" @click="assist.clearSelection()">Cancel</button>
</template>
<template v-else-if="assist.state.value === 'review'">
<div class="assist-active-hint">Review the diff in the main area</div>
<div class="assist-actions">
<button class="btn-accept" @click="handleAssistAccept">Accept</button>
<button class="btn-reject" @click="assist.reject()">Reject</button>
</div>
</template>
</div>
<VersionHistorySection
v-if="taskId"
:note-id="taskId"
:current-body="body"
@restore="(b, t) => { body = b; tags = t; markDirty(); }"
/>
</div><!-- /sidebar-content -->
</aside><!-- /task-sidebar -->
</div><!-- /task-body -->
<!-- Floating inline assist button -->
<teleport to="body">
<button
v-if="floatingAssist.show"
class="inline-assist-btn"
:style="{ top: floatingAssist.top + 'px', left: floatingAssist.left + 'px' }"
@mousedown.prevent="handleInlineAssist"
> Assist</button>
</teleport>
<!-- Delete confirmation -->
<ConfirmDialog
v-if="showDeleteConfirm"
title="Delete Task"
message="Are you sure you want to delete this task? This cannot be undone."
@confirm="confirmDelete"
@cancel="showDeleteConfirm = false"
/>
</main>
</template>
<style src="@/assets/editor-shared.css" />
<style scoped>
/* ── Two-column task layout ─────────────────────────────────── */
.task-editor-page {
/* override shared max-width; task editor can be wider */
max-width: 1600px;
}
/* The task editor's own body row. It began as a replacement for the shared
.editor-body, which nothing used afterwards and has since been deleted. */
.task-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
position: relative;
}
/* Main column: editor + work log */
.task-main {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 0.75rem 1.25rem 2rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* .task-main is a flex column; without flex-shrink: 0, long body content
gets squeezed back to min-height and overflows visibly on top of siblings. */
.body-editor-wrap,
.body-log {
flex-shrink: 0;
}
:deep(.preview-pane) {
flex-shrink: 0;
}
/* Right sidebar: metadata fields */
.task-sidebar {
width: 280px;
flex-shrink: 0;
border-left: 1px solid var(--fs-border-color);
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* Parent task search */
.parent-search-wrapper { position: relative; }
.parent-input-row { display: flex; align-items: center; gap: 0.25rem; }
.parent-input-row .sb-input { flex: 1; }
.btn-clear-parent {
background: none;
border: none;
cursor: pointer;
color: var(--fs-text-tertiary);
font-size: 1.1rem;
line-height: 1;
padding: 0 0.2rem;
flex-shrink: 0;
}
.btn-clear-parent:hover { color: var(--fs-error); }
.parent-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
box-shadow: 0 4px 12px var(--color-shadow);
z-index: 50;
max-height: 200px;
overflow-y: auto;
}
.parent-dropdown-item {
padding: 0.4rem 0.65rem;
font-size: 0.85rem;
cursor: pointer;
color: var(--fs-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.parent-dropdown-item:hover { background: var(--fs-surface-raised); }
.parent-empty { color: var(--fs-text-tertiary); cursor: default; }
.parent-empty:hover { background: none; }
/* Sub-tasks */
.subtasks-section {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.5rem 0.65rem;
background: var(--fs-surface-raised);
}
.subtasks-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
}
.subtasks-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
.subtask-title {
font-size: 0.83rem;
color: var(--fs-text-primary);
text-decoration: none;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtask-title:hover { color: var(--fs-accent); }
.subtask-title.done { text-decoration: line-through; color: var(--fs-text-tertiary); }
.subtasks-empty { font-size: 0.78rem; color: var(--fs-text-tertiary); margin: 0; }
.subtask-add-row {
display: flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.3rem;
}
.subtask-input {
flex: 1;
padding: 0.25rem 0.4rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.83rem;
font-family: inherit;
}
.subtask-input:focus { outline: none; border-color: var(--fs-accent); }
/* Systems multi-select (in sidebar) */
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--fs-text-primary); cursor: pointer; }
.sb-system-opt input { accent-color: var(--fs-accent); cursor: pointer; }
.sb-systems-empty { margin: 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
/* Writing Assistant section (in sidebar) */
.assist-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.assist-actions {
display: flex;
gap: 0.4rem;
}
/* Lifecycle timestamps */
.sb-timestamps {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-top: 0.25rem;
}
.sb-timestamp {
display: flex;
justify-content: space-between;
gap: 0.4rem;
font-size: 0.75rem;
}
.sb-ts-label {
color: var(--fs-text-tertiary);
flex-shrink: 0;
}
.sb-ts-value {
color: var(--fs-text-secondary);
text-align: right;
}
/* Narrow screen: sidebar collapses */
@media (max-width: 720px) {
.task-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
.task-main { padding: 0.75rem 1rem 1rem; overflow-y: visible; }
.task-sidebar {
width: 100%;
border-left: none;
border-top: 1px solid var(--fs-border-color);
overflow-y: visible;
}
}
/* ── Goal (description) input ─────────────────────────────────────────────── */
.task-goal {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0.5rem 0 0.25rem;
}
.task-goal-label {
font-family: var(--fs-font-display);
font-style: italic;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--fs-text-tertiary);
}
.task-goal-input {
width: 100%;
resize: vertical;
min-height: 2.4rem;
padding: 0.5rem 0.6rem;
font: inherit;
font-size: 0.95rem;
line-height: 1.4;
color: var(--fs-text-primary);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
}
.task-goal-input:focus {
outline: none;
border-color: var(--fs-accent);
}
</style>