feat: update dashboard view

This commit is contained in:
2026-07-16 02:05:36 +05:00
parent 1d1f11c3f8
commit bda6c6b2e0
14 changed files with 338 additions and 136 deletions
+2 -2
View File
@@ -88,7 +88,7 @@ Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
| **XBOCTuK Flow: Change Status** | Change status |
| **XBOCTuK Flow: Delete Task** | Delete task |
| **XBOCTuK Flow: Open Dashboard** | Dashboard (list + body) |
| **XBOCTuK Flow: Edit in Dashboard** | Dashboard with the selected task |
| **XBOCTuK Flow: Open in Dashboard** | Dashboard with the selected task |
| **XBOCTuK Flow: Open Kanban** | Kanban board |
Sidebar: title buttons (create / dashboard / kanban) and item actions (open /
@@ -120,7 +120,7 @@ One file per task. File name is derived from the title (slug).
- Search title/body; status/priority chips; group none/status/priority
- Scope: All / Project / Global
- Sort by **created**: Oldest first (default) / Newest first
- Save description (button / `Ctrl+S` / `Cmd+S`)
- View-only detail; **Edit** opens the task file in the editor
- `+ Task` — create
## Kanban
+2 -2
View File
@@ -88,7 +88,7 @@ Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
| **XBOCTuK Flow: Change Status** | Сменить статус |
| **XBOCTuK Flow: Delete Task** | Удалить задачу |
| **XBOCTuK Flow: Open Dashboard** | Dashboard (список + body) |
| **XBOCTuK Flow: Edit in Dashboard** | Dashboard с выбранной задачей |
| **XBOCTuK Flow: Open in Dashboard** | Dashboard с выбранной задачей |
| **XBOCTuK Flow: Open Kanban** | Kanban board |
В sidebar: кнопки title (create / dashboard / kanban) и actions на задаче (open
@@ -120,7 +120,7 @@ updated: 2026-07-12T10:00:00.000Z
- Поиск по title/body, chips status/priority, group none/status/priority
- Scope: All / Project / Global
- Sort by **created**: Oldest first (default) / Newest first
- Save description (кнопка / `Ctrl+S` / `Cmd+S`)
- Detail только просмотр; **Edit** открывает файл задачи в editor
- `+ Task` — create
## Kanban
+1 -1
View File
@@ -51,7 +51,7 @@
},
{
"command": "xboctukFlow.openInDashboard",
"title": "XBOCTuK Flow: Edit in Dashboard",
"title": "XBOCTuK Flow: Open in Dashboard",
"icon": "$(edit)"
},
{
+89
View File
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it } from "vitest";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { appError, AppErrorVariant } from "@/error";
import { TaskStatus } from "@/model/taskStatus";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { serializeTask } from "@/utils/markdown";
import { saveTaskRaw } from "./saveTaskRaw";
describe("saveTaskRaw", () => {
const folder = "/tasks";
let repo: FsTaskRepository;
beforeEach(() => {
repo = new FsTaskRepository(new InMemoryFileSystem(), ".task.md");
});
it("updates task from raw markdown", async () => {
const created = await repo.create(folder, {
title: "Raw me",
description: "old",
status: TaskStatus.TODO,
priority: "medium",
tags: [],
assignee: "",
});
const raw = serializeTask({
...created,
title: "Raw me renamed",
description: "new body",
status: TaskStatus.IN_PROGRESS,
});
const result = await saveTaskRaw(repo, {
folderPath: folder,
id: created.id,
raw,
});
expect(result.isOk()).toBe(true);
if (result.isErr()) return;
expect(result.value.id).toBe(created.id);
expect(result.value.title).toBe("Raw me renamed");
expect(result.value.description).toBe("new body");
expect(result.value.status).toBe(TaskStatus.IN_PROGRESS);
const stored = await repo.getById(folder, created.id);
expect(stored?.title).toBe("Raw me renamed");
});
it("returns not found for unknown id", async () => {
const result = await saveTaskRaw(repo, {
folderPath: folder,
id: "missing",
raw: "---\ntitle: x\n---\n",
});
expect(result).toEqual(
err(appError(AppErrorVariant.NOT_FOUND, { id: "missing" })),
);
});
it("rejects missing folder", async () => {
const result = await saveTaskRaw(repo, {
folderPath: undefined,
id: "x",
raw: "---\ntitle: x\n---\n",
});
expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER)));
});
it("rejects missing id", async () => {
const result = await saveTaskRaw(repo, {
folderPath: folder,
id: undefined,
raw: "---\ntitle: x\n---\n",
});
expect(result).toEqual(err(appError(AppErrorVariant.NO_ID)));
});
it("rejects missing raw", async () => {
const result = await saveTaskRaw(repo, {
folderPath: folder,
id: "x",
raw: undefined,
});
expect(result).toEqual(err(appError(AppErrorVariant.NO_RAW)));
});
});
+54
View File
@@ -0,0 +1,54 @@
import { errAsync, okAsync, ResultAsync } from "neverthrow";
import { applyRawTaskContent } from "@/dashboard/applyRawTaskContent";
import { appError, AppError, AppErrorVariant } from "@/error";
import { Task } from "@/model/task";
import { ITaskRepository } from "@/ports";
export type SaveTaskRawInput = {
folderPath?: string;
id?: string;
raw?: string;
};
export function saveTaskRaw(
repo: ITaskRepository,
input: SaveTaskRawInput,
): ResultAsync<Task, AppError> {
if (input.folderPath === undefined) {
return errAsync(appError(AppErrorVariant.NO_FOLDER));
}
if (input.id === undefined) {
return errAsync(appError(AppErrorVariant.NO_ID));
}
if (input.raw === undefined) {
return errAsync(appError(AppErrorVariant.NO_RAW));
}
const folderPath = input.folderPath;
const id = input.id;
const raw = input.raw;
return ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
(existing) => {
if (!existing) {
return errAsync(appError(AppErrorVariant.NOT_FOUND, { id }));
}
const applied = applyRawTaskContent(raw, existing);
if (applied.isErr()) {
return errAsync(applied.error);
}
const next = applied.value;
return ResultAsync.fromSafePromise(repo.update(folderPath, next)).andThen(
() =>
ResultAsync.fromSafePromise(repo.getById(folderPath, id)).andThen(
(updated) =>
updated
? okAsync(updated)
: errAsync(appError(AppErrorVariant.NOT_FOUND, { id })),
),
);
},
);
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { err } from "neverthrow";
import { appError, AppErrorVariant } from "@/error";
import { Task } from "@/model/task";
import { TaskStatus } from "@/model/taskStatus";
import { serializeTask } from "@/utils/markdown";
import { applyRawTaskContent } from "./applyRawTaskContent";
const previous: Task = {
id: "keep-me",
title: "Old title",
description: "old body",
status: TaskStatus.TODO,
priority: "medium",
tags: [],
assignee: "",
created: "2026-01-01T00:00:00.000Z",
updated: "2026-01-02T00:00:00.000Z",
};
describe("applyRawTaskContent", () => {
it("parses raw file and applies fields", () => {
const raw = serializeTask({
...previous,
id: "ignored-id",
title: "New title",
description: "new body",
status: TaskStatus.DONE,
priority: "high",
tags: ["a"],
assignee: "bob",
created: "2099-01-01T00:00:00.000Z",
});
const result = applyRawTaskContent(raw, previous);
expect(result.isOk()).toBe(true);
if (result.isErr()) return;
expect(result.value.id).toBe(previous.id);
expect(result.value.created).toBe(previous.created);
expect(result.value.title).toBe("New title");
expect(result.value.description).toBe("new body");
expect(result.value.status).toBe(TaskStatus.DONE);
expect(result.value.priority).toBe("high");
expect(result.value.tags).toEqual(["a"]);
expect(result.value.assignee).toBe("bob");
});
it("rejects empty title", () => {
const raw = `---
id: "x"
title: " "
status: todo
priority: medium
---
body
`;
const result = applyRawTaskContent(raw, previous);
expect(result).toEqual(err(appError(AppErrorVariant.EMPTY_TITLE)));
});
it("rejects unparseable content", () => {
// Invalid YAML inside frontmatter — front-matter / yaml throws.
const raw = "---\n:\n---\n";
const result = applyRawTaskContent(raw, previous);
expect(result.isErr()).toBe(true);
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_TASK_FILE);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { err, ok, Result } from "neverthrow";
import { appError, AppError, AppErrorVariant } from "@/error";
import { Task } from "@/model/task";
import { parseTaskFile } from "@/utils/markdown";
/**
* Parse full task file text into a Task, keeping stable id/created from previous.
*/
export function applyRawTaskContent(
raw: string,
previous: Task,
): Result<Task, AppError> {
let parsed: Task;
try {
parsed = parseTaskFile(raw);
} catch (cause) {
const detail =
cause instanceof Error ? cause.message : "unable to parse markdown";
return err(appError(AppErrorVariant.INVALID_TASK_FILE, { detail }));
}
const title = parsed.title?.trim() ?? "";
if (!title) {
return err(appError(AppErrorVariant.EMPTY_TITLE));
}
return ok({
...parsed,
title,
id: previous.id,
created: previous.created,
});
}
+26 -16
View File
@@ -44,20 +44,13 @@ describe("parseDashboardInboundMessage", () => {
).toEqual(ok({ variant: "setGroupBy", groupBy: GroupBy.STATUS }));
});
it("parses saveDescription", () => {
it("parses editTask", () => {
expect(
parseDashboardInboundMessage({
variant: "saveDescription",
variant: "editTask",
id: "abc",
description: "updated body",
}),
).toEqual(
ok({
variant: "saveDescription",
id: "abc",
description: "updated body",
}),
);
).toEqual(ok({ variant: "editTask", id: "abc" }));
});
it("parses refresh", () => {
@@ -137,12 +130,8 @@ describe("parseDashboardInboundMessage", () => {
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
});
it("rejects saveDescription with non-string description", () => {
const result = parseDashboardInboundMessage({
variant: "saveDescription",
id: "abc",
description: 42,
});
it("rejects editTask without id", () => {
const result = parseDashboardInboundMessage({ variant: "editTask" });
expect(result.isErr()).toBe(true);
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
@@ -157,4 +146,25 @@ describe("parseDashboardInboundMessage", () => {
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
});
it("rejects saveDescription (view-only dashboard)", () => {
const result = parseDashboardInboundMessage({
variant: "saveDescription",
id: "abc",
description: "x",
});
expect(result.isErr()).toBe(true);
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
});
it("rejects setDetailMode (no raw mode)", () => {
const result = parseDashboardInboundMessage({
variant: "setDetailMode",
mode: "raw",
});
expect(result.isErr()).toBe(true);
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
});
});
+3 -10
View File
@@ -18,7 +18,7 @@ export type DashboardInboundMessage =
| { variant: "selectTask"; id: string }
| { variant: "setFilter"; filter: TaskFilter }
| { variant: "setGroupBy"; groupBy: GroupBy }
| { variant: "saveDescription"; id: string; description: string }
| { variant: "editTask"; id: string }
| { variant: "refresh" }
| { variant: "createTask" }
| { variant: "setScopeFilter"; scope: DashboardScopeFilter }
@@ -78,18 +78,11 @@ export function parseDashboardInboundMessage(
return ok({ variant: "setGroupBy", groupBy: raw.groupBy });
}
case "saveDescription": {
case "editTask": {
if (typeof raw.id !== "string" || raw.id.length === 0) {
return invalid(variant);
}
if (typeof raw.description !== "string") {
return invalid(variant);
}
return ok({
variant: "saveDescription",
id: raw.id,
description: raw.description,
});
return ok({ variant: "editTask", id: raw.id });
}
case "setScopeFilter": {
+1 -2
View File
@@ -30,5 +30,4 @@ export type DashboardOutboundMessage =
sortDirection: TaskSortDirection;
availableScopes: TaskScope[];
}
| { variant: "error"; error: AppError }
| { variant: "saved"; task: Task };
| { variant: "error"; error: AppError };
+4
View File
@@ -3,9 +3,11 @@ export const AppErrorVariant = {
NO_ID: "no-id",
NO_STATUS: "no-status",
NO_DESCRIPTION: "no-description",
NO_RAW: "no-raw",
EMPTY_TITLE: "empty-title",
NOT_FOUND: "not-found",
INVALID_MESSAGE: "invalid-message",
INVALID_TASK_FILE: "invalid-task-file",
} as const;
export type AppErrorVariant =
@@ -24,9 +26,11 @@ const APP_ERROR_MESSAGES: Record<AppErrorVariant, string> = {
[AppErrorVariant.NO_ID]: "Task id is required",
[AppErrorVariant.NO_STATUS]: "Task status is required",
[AppErrorVariant.NO_DESCRIPTION]: "Task description is required",
[AppErrorVariant.NO_RAW]: "Task raw content is required",
[AppErrorVariant.EMPTY_TITLE]: "Task title is empty",
[AppErrorVariant.NOT_FOUND]: "Task not found: {id}",
[AppErrorVariant.INVALID_MESSAGE]: "Invalid message: {detail}",
[AppErrorVariant.INVALID_TASK_FILE]: "Invalid task file: {detail}",
};
export function formatMessage(
+22 -49
View File
@@ -47,7 +47,7 @@ function sortDirectionOptionsHtml(): string {
).join("");
}
/** Minimal split-layout document for the Task Dashboard webview. */
/** List + read-only detail document for the Task Dashboard webview. */
export function getTaskDashboardHtml(
webview: vscode.Webview,
nonce: string,
@@ -106,7 +106,7 @@ export function getTaskDashboardHtml(
flex-wrap: wrap;
align-items: center;
}
input[type="search"], select, textarea, button {
input[type="search"], select, button {
font: inherit;
color: var(--vscode-input-foreground);
background: var(--vscode-input-background);
@@ -212,19 +212,30 @@ export function getTaskDashboardHtml(
flex: 1;
min-height: 160px;
width: 100%;
resize: vertical;
overflow: auto;
margin: 0;
padding: 10px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--vscode-editor-font-family, var(--vscode-font-family));
font-size: var(--vscode-editor-font-size, inherit);
background: var(--vscode-textCodeBlock-background, var(--vscode-input-background));
border: 1px solid var(--vscode-input-border, transparent);
border-radius: 2px;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
align-items: center;
}
#status-line {
min-height: 1.2em;
font-size: 0.85em;
opacity: 0.85;
flex: 1;
text-align: left;
}
#status-line.error { color: var(--vscode-errorForeground); }
</style>
@@ -263,10 +274,10 @@ export function getTaskDashboardHtml(
<div id="detail">
<h1 id="detail-title"></h1>
<div id="detail-meta"></div>
<textarea id="description" spellcheck="true" placeholder="Task description (markdown)…"></textarea>
<pre id="description"></pre>
<div class="actions">
<span id="status-line"></span>
<button id="save" type="button">Save</button>
<button id="edit" type="button">Edit</button>
</div>
</div>
</main>
@@ -289,7 +300,6 @@ export function getTaskDashboardHtml(
selectedId: undefined,
groups: [],
selected: null,
dirty: false,
scopeFilter: "all",
sortDirection: SORT_ASC,
availableScopes: [],
@@ -311,7 +321,7 @@ export function getTaskDashboardHtml(
title: document.getElementById("detail-title"),
meta: document.getElementById("detail-meta"),
description: document.getElementById("description"),
save: document.getElementById("save"),
edit: document.getElementById("edit"),
statusLine: document.getElementById("status-line"),
};
@@ -399,14 +409,6 @@ export function getTaskDashboardHtml(
);
}
function saveDescription() {
if (!state.selected) return;
post({ variant: "saveDescription",
id: state.selected.id,
description: el.description.value,
});
}
function resetFilters() {
state.filter = {};
state.groupBy = GROUP_BY_NONE;
@@ -447,11 +449,6 @@ export function getTaskDashboardHtml(
escapeHtml(task.priority) +
"</div>";
btn.addEventListener("click", () => {
if (state.dirty) {
const okLeave = window.confirm("Discard unsaved description changes?");
if (!okLeave) return;
state.dirty = false;
}
post({ variant: "selectTask", id: task.id });
});
el.list.appendChild(btn);
@@ -477,9 +474,7 @@ export function getTaskDashboardHtml(
task.priority +
(task.tags && task.tags.length ? " · " + task.tags.join(", ") : "") +
(task.assignee ? " · @" + task.assignee : "");
if (!state.dirty) {
el.description.value = task.description || "";
}
el.description.textContent = task.description || "";
}
function escapeHtml(value) {
@@ -499,15 +494,14 @@ export function getTaskDashboardHtml(
state.scopeFilter = message.scopeFilter || "all";
state.sortDirection = message.sortDirection || SORT_ASC;
state.availableScopes = message.availableScopes || [];
state.dirty = false;
el.query.value = state.filter.query || "";
el.groupBy.value = state.groupBy;
el.sortDirection.value = state.sortDirection;
renderScopeFilter();
renderFilterChips();
renderList();
renderDetail();
setStatus("");
renderDetail();
}
let queryTimer;
@@ -533,7 +527,6 @@ export function getTaskDashboardHtml(
});
el.refresh.addEventListener("click", () => {
state.dirty = false;
post({ variant: "refresh" });
});
@@ -545,20 +538,9 @@ export function getTaskDashboardHtml(
resetFilters();
});
el.description.addEventListener("input", () => {
state.dirty = true;
setStatus("Unsaved changes");
});
el.save.addEventListener("click", () => {
saveDescription();
});
window.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
saveDescription();
}
el.edit.addEventListener("click", () => {
if (!state.selected) return;
post({ variant: "editTask", id: state.selected.id });
});
window.addEventListener("message", (event) => {
@@ -568,15 +550,6 @@ export function getTaskDashboardHtml(
applyState(message);
} else if (message.variant === "error") {
setStatus((message.error && message.error.message) || "Error", true);
} else if (message.variant === "saved") {
state.dirty = false;
if (state.selected && state.selected.id === message.task.id) {
state.selected = Object.assign({}, message.task, {
scope: state.selected.scope,
});
}
setStatus("Saved");
renderDetail();
}
});
+16 -23
View File
@@ -1,7 +1,7 @@
import * as vscode from "vscode";
import { listLocatedTasks } from "@/commands/listLocatedTasks";
import { openTask } from "@/commands/openTask";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { saveTaskDescription } from "@/commands/saveTaskDescription";
import { buildDashboardView } from "@/dashboard/buildDashboardView";
import { filterLocatedByScope } from "@/dashboard/filterLocatedByScope";
import { TaskFilter } from "@/dashboard/filterTasks";
@@ -25,7 +25,7 @@ import { createNonce, getTaskDashboardHtml } from "./taskDashboardHtml";
export type TaskDashboardDeps = {
repo: ITaskRepository;
config: IConfigProvider;
/** Called after dashboard mutates tasks (e.g. save description). */
/** Called after dashboard mutates tasks (e.g. create). */
onTasksMutated?: () => void;
};
@@ -34,10 +34,7 @@ export type TaskDashboardShowOptions = {
selectedId?: string;
};
/**
* Host adapter for the Task Dashboard webview.
* Owns panel lifecycle and message wiring; business rules stay in use-cases.
*/
/** Host adapter for the Task Dashboard webview. */
export class TaskDashboardPanel {
static #current: TaskDashboardPanel | undefined;
@@ -176,18 +173,21 @@ export class TaskDashboardPanel {
this.#postState();
return;
case "saveDescription":
await this.#saveDescription(message.id, message.description);
case "editTask":
this.#selectedId = message.id;
this.#postState();
await this.#openTaskInEditor(message.id);
return;
case "createTask":
await vscode.commands.executeCommand("xboctukFlow.createTask");
await this.reloadTasks();
this.#deps.onTasksMutated?.();
return;
}
}
async #saveDescription(id: string, description: string): Promise<void> {
async #openTaskInEditor(id: string): Promise<void> {
const located = this.#tasks.find((item) => item.task.id === id);
if (!located) {
this.#post({
@@ -197,27 +197,20 @@ export class TaskDashboardPanel {
return;
}
const result = await saveTaskDescription(this.#deps.repo, {
const result = await openTask(this.#deps.repo, {
folderPath: located.location.folderPath,
id,
description,
id: located.task.id,
});
if (result.isErr()) {
this.#post({ variant: "error", error: result.error });
return;
}
const saved = result.value;
this.#tasks = this.#tasks.map((item) =>
item.task.id === saved.id
? { task: saved, location: item.location }
: item,
);
this.#selectedId = saved.id;
this.#post({ variant: "saved", task: saved });
this.#postState();
this.#deps.onTasksMutated?.();
const uri = vscode.Uri.file(result.value.path);
await vscode.window.showTextDocument(uri, {
preview: false,
preserveFocus: false,
});
}
#scopeById(): Map<string, TaskScope> {
+14 -31
View File
@@ -2,31 +2,10 @@
## Dashboard
[x] **Сохранение по Ctrl+S**
[x] **Редактирование в editor (не Save в webview)**
- В detail (textarea description) — hotkey `Ctrl+S` / `Cmd+S` → тот же flow, что
кнопка Save (`saveDescription`).
[ ] **Форматирование при сохранении (как в VS Code, без force-format)**
**Почему сейчас не работает:** кнопка Save в Dashboard пишет через
`saveTaskDescription``repo.update``fs.writeFile`. Это **не** save
текстового документа в editor. `editor.formatOnSave` / formatters цепляются к
`TextDocument` save (`onWillSaveTextDocument` и т.п.), а не к произвольной
записи на диск. Обычный Ctrl+S в `.md` / `.task.md` идёт через editor → format
применяется. Наша кнопка — другой путь.
**Не делать:** вручную вызывать prettier/`formatDocument` «всегда» — это не «как
в VS Code», а свой форс, игнорит user settings (off / другой formatter).
**Как сделать «как в VS Code» (если захотим паритет):** UI-адаптер save
открывает/берёт `TextDocument` файла задачи, правит body через `WorkspaceEdit`,
затем `document.save()` — тогда сработают format-on-save и остальные will-save
хуки, **только если** они включены у пользователя. Use-case/repo для
headless/тестов можно оставить; editor-save — optional path из host.
**Пока:** не блокер; format при правке файла в editor уже ок. Ctrl+S в Dashboard
без editor-path format не даст.
- Dashboard view-only; Edit → файл в editor → Ctrl+S / format-on-save как обычно
в VS Code.
[x] **Кнопка сброса фильтров**
@@ -37,18 +16,18 @@ headless/тестов можно оставить; editor-save — optional path
- Chips: critical / high / medium / low → `filter.priorities`.
[ ] **Dashboard: raw MD ↔ view**
[x] **Dashboard: view-only + Edit в editor**
- В detail переключатель режимов: **view** (текущий UI: meta + textarea body) и
**raw** (весь файл как markdown + frontmatter, как в editor).
- Редактирование/save в raw — отдельный контракт (parse → task / serialize), не
ломать format-on-save историю: raw-save тоже лучше через editor path, если
нужен паритет с VS Code.
- **View:** meta + body (read-only) в webview.
- **Edit:** кнопка → `openTask` + `showTextDocument` — полный `.task.md` в
editor.
- Use-case `saveTaskRaw` / `applyRawTaskContent` / `saveTaskDescription`
остаются для non-webview path.
[x] **Боковая панель**
- кнопка открытия dashboard (view title → `xboctukFlow.openDashboard`)
- кнопки на задаче (inline): open file, edit in dashboard
- кнопки на задаче (inline): open file, open in dashboard
(`xboctukFlow.openInDashboard` + preselect), delete
- change status остаётся в context menu
@@ -75,3 +54,7 @@ headless/тестов можно оставить; editor-save — optional path
- в списке и detail — scope (Project / Global)
- сортировка по **created**: по умолчанию старые сверху (ASC), toggle Newest
first
[ ] **Боковая панель**
- при изменении настроек должна обновляться (или добавить кнопку refresh)