659c08def5
TiptapEditor.vue (central — applies to all three editors): - Escape inside editor blurs it and emits 'escape' event to parent TagInput.vue (central — applies everywhere tags are used): - ArrowUp/ArrowDown navigate autocomplete suggestion list with visual highlight - Enter confirms the keyboard-selected suggestion instead of typed text NoteEditorView, TaskEditorView, WorkspaceNoteEditor (per-editor wiring): - @escape on TiptapEditor returns focus to the title input (ref="titleRef") - Ctrl+E from title input or editor main column jumps focus into editor body - Ctrl+S on title input saves (was already on editor area; now consistent) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
856 lines
28 KiB
Vue
856 lines
28 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, computed, nextTick } 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 { Editor } from "@tiptap/vue-3";
|
||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||
import TiptapEditor from "@/components/TiptapEditor.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 InlineAssistPanel from "@/components/InlineAssistPanel.vue";
|
||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const store = useTasksStore();
|
||
const notesStore = useNotesStore();
|
||
const toast = useToastStore();
|
||
|
||
const title = ref("");
|
||
const body = ref("");
|
||
const tags = ref<string[]>([]);
|
||
const status = ref<TaskStatus>("todo");
|
||
const priority = ref<TaskPriority>("none");
|
||
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 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(true);
|
||
const sidebarOpen = ref(true);
|
||
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);
|
||
|
||
const assistLabel = computed(() => {
|
||
if (assist.isProofreading.value) return "Proofreading document...";
|
||
const t = assist.target.value;
|
||
if (!t) return "Generating...";
|
||
const heading = t.text.split("\n")[0];
|
||
return `Revising: "${heading.length > 50 ? heading.slice(0, 50) + "..." : heading}"`;
|
||
});
|
||
|
||
// Assist panel toggle (persisted)
|
||
const ASSIST_KEY = 'fa-assist-open';
|
||
const assistOpen = ref(localStorage.getItem(ASSIST_KEY) !== 'false');
|
||
function toggleAssist() {
|
||
assistOpen.value = !assistOpen.value;
|
||
localStorage.setItem(ASSIST_KEY, String(assistOpen.value));
|
||
}
|
||
|
||
// Floating inline assist button
|
||
const instructionRef = ref<HTMLTextAreaElement | null>(null);
|
||
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
|
||
({ start, end }) => {
|
||
assist.selectTextRange(start, end);
|
||
assistOpen.value = true;
|
||
localStorage.setItem(ASSIST_KEY, 'true');
|
||
nextTick(() => instructionRef.value?.focus());
|
||
}
|
||
);
|
||
|
||
function handleAssistAccept() {
|
||
const newBody = assist.accept();
|
||
if (newBody !== body.value) {
|
||
body.value = newBody;
|
||
markDirty();
|
||
toast.show("Section updated");
|
||
}
|
||
}
|
||
|
||
function truncateTarget(text: string, max = 60): string {
|
||
const first = text.split("\n")[0];
|
||
return first.length > max ? first.slice(0, max) + "..." : first;
|
||
}
|
||
|
||
// Tag suggestions
|
||
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
|
||
useTagSuggestions(title, body, tags, markDirty);
|
||
|
||
let savedTitle = "";
|
||
let savedBody = "";
|
||
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;
|
||
|
||
function markDirty() {
|
||
dirty.value =
|
||
title.value !== savedTitle ||
|
||
body.value !== savedBody ||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
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;
|
||
parentId.value = (taskRec.parent_id as number | null) ?? null;
|
||
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
|
||
parentSearchQuery.value = parentTitle.value;
|
||
savedTitle = title.value;
|
||
savedBody = body.value;
|
||
savedTags = [...tags.value];
|
||
savedStatus = status.value;
|
||
savedPriority = priority.value;
|
||
savedDueDate = dueDate.value;
|
||
savedProjectId = projectId.value;
|
||
savedMilestoneId = milestoneId.value;
|
||
savedParentId = parentId.value;
|
||
}
|
||
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);
|
||
}
|
||
});
|
||
|
||
async function save() {
|
||
if (saving.value) return;
|
||
saving.value = true;
|
||
try {
|
||
const data = {
|
||
title: title.value,
|
||
body: body.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,
|
||
};
|
||
if (isEditing.value) {
|
||
await store.updateTask(taskId.value!, data);
|
||
savedTitle = title.value;
|
||
savedBody = body.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("Task saved");
|
||
} 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("/tasks");
|
||
} 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,
|
||
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,
|
||
} as Record<string, unknown>);
|
||
savedTitle = title.value;
|
||
savedBody = body.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="/tasks" class="btn-back">Back</router-link>
|
||
<button class="btn-save" @click="save" :disabled="saving">
|
||
{{ saving ? "Saving..." : "Save" }}
|
||
</button>
|
||
<button v-if="isEditing" class="btn-delete" @click="remove">
|
||
Delete
|
||
</button>
|
||
<button class="btn-assist-toggle" :class="{ active: assistOpen }" @click="toggleAssist">
|
||
✨ Assist
|
||
</button>
|
||
</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><!-- /editor-header: title only -->
|
||
|
||
<!-- 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" :editor="tiptapEditor" />
|
||
</div>
|
||
|
||
<!-- Inline assist output -->
|
||
<InlineAssistPanel
|
||
v-if="assist.state.value !== 'idle'"
|
||
:phase="assist.state.value as 'streaming' | 'review'"
|
||
:label="assistLabel"
|
||
:streaming-text="assist.streamingText.value"
|
||
:diff="assist.diff.value"
|
||
:proposed-text="assist.proposedText.value"
|
||
@accept="handleAssistAccept"
|
||
@reject="assist.reject()"
|
||
@cancel="assist.clearSelection()"
|
||
/>
|
||
|
||
<!-- Editor / preview (hidden during review) -->
|
||
<div v-show="!showPreview && assist.state.value !== 'review'" 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 && assist.state.value !== 'review'"
|
||
class="preview-pane prose"
|
||
v-html="renderedPreview"
|
||
></div>
|
||
|
||
<!-- Work log -->
|
||
<TaskLogSection v-if="taskId" :task-id="taskId" class="body-log" />
|
||
</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>
|
||
</select>
|
||
</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">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>
|
||
|
||
<!-- 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-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-add-subtask" @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-subtask-confirm" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
|
||
<button class="btn-subtask-cancel" @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" @click="dismissTagSuggestions">×</button>
|
||
</template>
|
||
</div>
|
||
|
||
</div><!-- /sidebar-content -->
|
||
</aside><!-- /task-sidebar -->
|
||
|
||
<!-- ✨ Assist panel overlays on top of sidebar when open -->
|
||
<aside v-if="assistOpen" class="assist-panel">
|
||
<div class="assist-panel-header">
|
||
<span class="assist-panel-title">✨ AI Assist</span>
|
||
<button class="btn-proofread" @click="assist.proofread()" :disabled="assist.state.value === 'streaming'">Proofread</button>
|
||
<button class="btn-close-assist" @click="toggleAssist">×</button>
|
||
</div>
|
||
<div class="assist-panel-body">
|
||
<div v-if="assist.state.value === 'idle'" class="assist-idle">
|
||
<div class="assist-sections-label">Sections</div>
|
||
<div class="assist-sections">
|
||
<div
|
||
v-for="(section, i) in assist.sections.value"
|
||
:key="i"
|
||
:class="['assist-section-item', { selected: assist.selectedSection.value === section }]"
|
||
@click="assist.selectSection(section)"
|
||
>{{ section.heading || '(preamble)' }}</div>
|
||
<div v-if="!assist.sections.value.length" class="assist-empty">Write some content to get started.</div>
|
||
</div>
|
||
<template v-if="assist.target.value">
|
||
<div class="assist-target-preview">Editing: <em>{{ truncateTarget(assist.target.value.text) }}</em></div>
|
||
<textarea
|
||
ref="instructionRef"
|
||
v-model="assist.instruction.value"
|
||
placeholder="What should I do with this section?"
|
||
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-clear" @click="assist.clearSelection()">Clear</button>
|
||
</div>
|
||
</template>
|
||
<div v-else class="assist-hint">Select a section above or highlight text in the editor.</div>
|
||
</div>
|
||
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
|
||
<div v-if="assist.state.value !== 'idle'" class="assist-active-hint">
|
||
{{ assist.state.value === 'streaming' ? 'Generating in editor…' : 'Review diff in editor ↑' }}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
</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: column;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.body-editor-wrap {
|
||
min-height: 200px;
|
||
}
|
||
|
||
.body-log {
|
||
/* TaskLogSection already has its own border/bg */
|
||
}
|
||
|
||
/* Right sidebar: metadata fields */
|
||
.task-sidebar {
|
||
width: 280px;
|
||
flex-shrink: 0;
|
||
border-left: 1px solid var(--color-border);
|
||
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(--color-text-muted);
|
||
font-size: 1.1rem;
|
||
line-height: 1;
|
||
padding: 0 0.2rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
|
||
.parent-dropdown {
|
||
position: absolute;
|
||
top: calc(100% + 4px);
|
||
left: 0;
|
||
right: 0;
|
||
background: var(--color-bg-card);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--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(--color-text);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.parent-dropdown-item:hover { background: var(--color-bg-secondary); }
|
||
.parent-empty { color: var(--color-text-muted); cursor: default; }
|
||
.parent-empty:hover { background: none; }
|
||
|
||
/* Sub-tasks */
|
||
.subtasks-section {
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 0.5rem 0.65rem;
|
||
background: var(--color-bg-secondary);
|
||
}
|
||
.subtasks-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 0.35rem;
|
||
}
|
||
.subtasks-label {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.btn-add-subtask {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: var(--color-primary);
|
||
font-size: 0.78rem;
|
||
font-family: inherit;
|
||
padding: 0.1rem 0.2rem;
|
||
}
|
||
.btn-add-subtask:hover { opacity: 0.8; }
|
||
.subtasks-loading { font-size: 0.78rem; color: var(--color-text-muted); }
|
||
.subtask-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
padding: 0.2rem 0;
|
||
}
|
||
.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
|
||
.subtask-title {
|
||
font-size: 0.83rem;
|
||
color: var(--color-text);
|
||
text-decoration: none;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.subtask-title:hover { color: var(--color-primary); }
|
||
.subtask-title.done { text-decoration: line-through; color: var(--color-text-muted); }
|
||
.subtasks-empty { font-size: 0.78rem; color: var(--color-text-muted); 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(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-bg);
|
||
color: var(--color-text);
|
||
font-size: 0.83rem;
|
||
font-family: inherit;
|
||
}
|
||
.subtask-input:focus { outline: none; border-color: var(--color-primary); }
|
||
.btn-subtask-confirm {
|
||
padding: 0.25rem 0.5rem;
|
||
background: var(--color-primary);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 0.78rem;
|
||
font-family: inherit;
|
||
}
|
||
.btn-subtask-confirm:disabled { opacity: 0.5; cursor: default; }
|
||
.btn-subtask-cancel {
|
||
padding: 0.25rem 0.5rem;
|
||
background: none;
|
||
border: 1px solid var(--color-border);
|
||
color: var(--color-text-secondary);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 0.78rem;
|
||
font-family: inherit;
|
||
}
|
||
|
||
/* Tag suggest row inside sidebar */
|
||
.tag-suggest-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.3rem;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 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(--color-border);
|
||
overflow-y: visible;
|
||
}
|
||
}
|
||
</style> |