CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.
73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.
One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.
Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.
Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).
Refs #2533
1064 lines
35 KiB
Vue
1064 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>
|
|
<!-- '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">×</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">✓</span>
|
|
</button>
|
|
<button class="btn-dismiss-tags" aria-label="Dismiss tag suggestions" @click="dismissTagSuggestions">×</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;
|
|
}
|
|
|
|
/* Replace .editor-body for task editor */
|
|
.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;
|
|
}
|
|
|
|
.body-tabs-row {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
padding-bottom: 0.5rem;
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
}
|
|
|
|
/* .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;
|
|
}
|
|
|
|
.body-editor-wrap {
|
|
min-height: 200px;
|
|
}
|
|
|
|
: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); }
|
|
.stream-preview {
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-sm);
|
|
padding: 0.75rem;
|
|
background: var(--fs-surface-raised);
|
|
min-height: 200px;
|
|
}
|
|
.main-diff {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* 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-section-title {
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
color: var(--fs-text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
.assist-actions {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
/* Tag suggest row inside sidebar */
|
|
.tag-suggest-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.3rem;
|
|
align-items: center;
|
|
}
|
|
|
|
/* 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> |