Files
FabledScribe/frontend/src/views/TaskEditorView.vue
T
bvandeusen 404aba1461 Task UX: forward-only advance button, auto edit mode, reload on save
TaskViewerView:
- Add '→ In Progress' / '→ Done' advance button in toolbar (forward-only,
  hidden when status is already done)
- Styled with primary indigo gradient to distinguish from secondary actions

TaskEditorView:
- showPreview now defaults false; set to true after load only when body
  has content — empty tasks open directly in Write mode
- Save on an existing task now calls window.location.reload() so the
  preview/edit mode re-evaluates from the freshly persisted body

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 18:50:36 -04:00

861 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 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 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(false);
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;
// 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);
}
});
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");
window.location.reload();
} 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>
<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><!-- /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">&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-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">&#10003;</span>
</button>
<button class="btn-dismiss-tags" aria-label="Dismiss tag suggestions" @click="dismissTagSuggestions">&times;</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" aria-label="Close assist panel" @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>