From 9b08c82548b0a7300dafa44d912ee6a349dbb680 Mon Sep 17 00:00:00 2001 From: Nikolai Giman Date: Mon, 29 Jun 2026 00:53:53 +0200 Subject: [PATCH] feat: inbox --- .claude-plugin/marketplace.json | 13 +++++ .gitignore | 2 + api/package.json | 4 +- api/src/migrations/taskview/migrate.json | 15 ++++++ .../sql/1.55.0/0.alter-goals-add-is-inbox.sql | 4 ++ .../sql/1.55.0/1.backfill-inbox-goals.sql | 17 ++++++ .../sql/1.55.0/2.unique-inbox-per-org.sql | 7 +++ api/src/tv-modules/auth/AuthController.ts | 3 ++ api/src/tv-modules/goals/GoalsManager.ts | 20 +++++++ api/src/tv-modules/goals/GoalsRepository.ts | 54 +++++++++++++++++-- api/src/tv-modules/goals/types.ts | 6 +++ api/src/tv-modules/sso/SsoController.ts | 3 ++ package.json | 4 +- .../taskview-api/src/api/goals.types.ts | 1 + .../src/schemas/goals.schema.ts | 3 +- taskview-plugin/.claude-plugin/plugin.json | 24 +++++++++ taskview-plugin/.mcp.json | 12 +++++ taskview-plugin/README.md | 38 +++++++++++++ taskview-plugin/commands/taskview-log.md | 13 +++++ taskview-plugin/commands/taskview-new-task.md | 14 +++++ taskview-plugin/commands/taskview-projects.md | 11 ++++ taskview-plugin/commands/taskview-tasks.md | 14 +++++ taskview-plugin/skills/taskview/SKILL.md | 51 ++++++++++++++++++ web/package.json | 4 +- .../parts/MainScreenAddTaskDialog.vue | 33 ++++++------ .../main/screen-main/parts/NoGoals.vue | 2 +- .../features/projects/ProjectsSidebar.vue | 4 +- .../features/tasks/TvTaskDetailOverlay.vue | 16 ++++++ .../sidebars/DashboardSidebarFirst.vue | 5 ++ .../sidebars/DashboardSidebarSecond.vue | 6 ++- .../components/sidebars/SidebarInboxLink.vue | 34 ++++++++++++ .../dashboard-second/SidebarProjectSelect.vue | 22 +++++--- web/src/composables/useInbox.ts | 13 +++++ web/src/composables/useTaskView.ts | 2 +- web/src/locales/en.ts | 1 + web/src/locales/es.ts | 1 + web/src/locales/ru.ts | 1 + web/src/types/goals.types.ts | 1 + web/vite.config.ts | 2 +- 39 files changed, 439 insertions(+), 41 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 api/src/migrations/taskview/sql/1.55.0/0.alter-goals-add-is-inbox.sql create mode 100644 api/src/migrations/taskview/sql/1.55.0/1.backfill-inbox-goals.sql create mode 100644 api/src/migrations/taskview/sql/1.55.0/2.unique-inbox-per-org.sql create mode 100644 taskview-plugin/.claude-plugin/plugin.json create mode 100644 taskview-plugin/.mcp.json create mode 100644 taskview-plugin/README.md create mode 100644 taskview-plugin/commands/taskview-log.md create mode 100644 taskview-plugin/commands/taskview-new-task.md create mode 100644 taskview-plugin/commands/taskview-projects.md create mode 100644 taskview-plugin/commands/taskview-tasks.md create mode 100644 taskview-plugin/skills/taskview/SKILL.md create mode 100644 web/src/components/sidebars/SidebarInboxLink.vue create mode 100644 web/src/composables/useInbox.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..1e1d6c4 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,13 @@ +{ + "name": "taskview", + "owner": { + "name": "Nikolai Giman" + }, + "plugins": [ + { + "name": "taskview", + "source": "./taskview-plugin", + "description": "Manage TaskView projects and tasks from Claude Code. Bundles the TaskView MCP server, skills, and slash commands." + } + ] +} diff --git a/.gitignore b/.gitignore index 6044e78..d0ed894 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ ReleasesBuilds/* iconcreator *.prod.* .DS_Store +.cursor +.claude node_modules __APP_BUILD__ ssl-create diff --git a/api/package.json b/api/package.json index 41acb8b..ab71a9a 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "taskview-ce-api-server", - "version": "1.48.4", + "version": "1.48.7", "scripts": { "dev": "bun run --watch ./server.ts", "start": "NODE_ENV=production node ./dist/taskview-server.js", @@ -83,4 +83,4 @@ "engines": { "node": ">=24 <25" } -} \ No newline at end of file +} diff --git a/api/src/migrations/taskview/migrate.json b/api/src/migrations/taskview/migrate.json index b92cefc..8b46d1a 100644 --- a/api/src/migrations/taskview/migrate.json +++ b/api/src/migrations/taskview/migrate.json @@ -640,5 +640,20 @@ "description": [ "Added tasks.recurrence_rules.has_time — explicit flag for whether a series is anchored to a wall-clock time or is date-only. Previously the code inferred 'no time' from a midnight dtstart, which silently collapsed an explicit 00:00 series into date-only. Backfill (has_time = dtstart::time <> '00:00:00') reproduces the old inference so existing series keep their behavior; new series carry the flag through from the origin task's start_time (null = date-only, set = timed, including midnight)." ] + }, + "50": { + "version": "1.55.0", + "name": "Release 1.55.0", + "releaseDate": "20260628", + "scripts": [ + "/1.55.0/0.alter-goals-add-is-inbox.sql", + "/1.55.0/1.backfill-inbox-goals.sql", + "/1.55.0/2.unique-inbox-per-org.sql" + ], + "description": [ + "Added tasks.goals.is_inbox (BOOLEAN NOT NULL DEFAULT FALSE) — flags a project as the user's personal Inbox. One Inbox per personal organization, auto-created at signup and guarded against deletion and archival in GoalsManager.", + "Backfill: for every personal organization without an Inbox, inserts one tasks.goals row (name='Inbox', is_inbox=true, owner=the org owner, organization_id=the org). The existing AFTER INSERT triggers fully provision it like any project — default kanban statuses, the owner added to collaboration, and the default editor/executor roles with their permissions. Idempotent — skips orgs that already have an Inbox.", + "Partial unique index goals_one_inbox_per_org_uidx ON tasks.goals (organization_id) WHERE is_inbox — enforces at most one Inbox per organization at the DB level, hardening the check-then-insert against concurrent races." + ] } } \ No newline at end of file diff --git a/api/src/migrations/taskview/sql/1.55.0/0.alter-goals-add-is-inbox.sql b/api/src/migrations/taskview/sql/1.55.0/0.alter-goals-add-is-inbox.sql new file mode 100644 index 0000000..019c54b --- /dev/null +++ b/api/src/migrations/taskview/sql/1.55.0/0.alter-goals-add-is-inbox.sql @@ -0,0 +1,4 @@ +ALTER TABLE tasks.goals + ADD COLUMN IF NOT EXISTS is_inbox BOOLEAN NOT NULL DEFAULT FALSE; + -- Flags a project as the user's personal Inbox. One Inbox per personal organization; + -- drives auto-create at signup, the backfill below, and the delete/archive guard. diff --git a/api/src/migrations/taskview/sql/1.55.0/1.backfill-inbox-goals.sql b/api/src/migrations/taskview/sql/1.55.0/1.backfill-inbox-goals.sql new file mode 100644 index 0000000..8430afd --- /dev/null +++ b/api/src/migrations/taskview/sql/1.55.0/1.backfill-inbox-goals.sql @@ -0,0 +1,17 @@ +-- For every personal organization that has no Inbox yet, create one. +-- The AFTER INSERT triggers on tasks.goals fully provision the goal, exactly like +-- a normal project: default kanban statuses (kanban_add_default_columns), the owner +-- added to collaboration (add_self_to_collaboration), and the default editor/executor +-- roles with their permissions (add_roles_after_insert). The owner also has full +-- permissions implicitly. +-- Idempotent: orgs that already have an Inbox are skipped. +INSERT INTO tasks.goals (name, owner, organization_id, is_inbox) +SELECT 'Inbox', o.owner_id, o.id, TRUE +FROM tv_auth.organizations o +WHERE o.is_personal = 1 + AND NOT EXISTS ( + SELECT 1 + FROM tasks.goals g + WHERE g.organization_id = o.id + AND g.is_inbox = TRUE + ); diff --git a/api/src/migrations/taskview/sql/1.55.0/2.unique-inbox-per-org.sql b/api/src/migrations/taskview/sql/1.55.0/2.unique-inbox-per-org.sql new file mode 100644 index 0000000..2dda757 --- /dev/null +++ b/api/src/migrations/taskview/sql/1.55.0/2.unique-inbox-per-org.sql @@ -0,0 +1,7 @@ +-- Hard guarantee of at most one Inbox per organization. Defends the +-- check-then-insert in GoalsRepository.createInboxGoal against concurrent +-- signups/calls that could both pass the findInboxGoal precheck and insert. +-- Safe to create here: it runs after the idempotent backfill, so no duplicates exist. +CREATE UNIQUE INDEX IF NOT EXISTS goals_one_inbox_per_org_uidx +ON tasks.goals (organization_id) +WHERE is_inbox; diff --git a/api/src/tv-modules/auth/AuthController.ts b/api/src/tv-modules/auth/AuthController.ts index d5f2356..cd48489 100644 --- a/api/src/tv-modules/auth/AuthController.ts +++ b/api/src/tv-modules/auth/AuthController.ts @@ -20,6 +20,7 @@ import RuEmailTemplate from './mail/confirm-email-ru'; import LoginCodeEmailTemplate from './mail/login-code-en'; import type { ExternalAuthUser } from './strategies/external-auth.types'; import { OrganizationRepository } from '../organizations/OrganizationRepository'; +import { GoalsRepository } from '../goals/GoalsRepository'; const LOGIN_CODE_TTL_MS = 5 * 60 * 1000; @@ -30,12 +31,14 @@ export default class AuthController { private readonly refreshTokenCookieName: string = 'taskview-refresh'; private readonly orgRepository: OrganizationRepository = new OrganizationRepository(); + private readonly goalsRepository: GoalsRepository = new GoalsRepository(); private async createPersonalWorkspace(userId: number, email: string, login: string) { const slug = `org-${crypto.randomUUID().slice(0, 8)}` const org = await this.orgRepository.create({ name: `${login}'s workspace`, slug }, userId, true) if (org) { await this.orgRepository.addMember(org.id, email, 'owner') + await this.goalsRepository.createInboxGoal({ ownerId: userId, organizationId: org.id }) } } diff --git a/api/src/tv-modules/goals/GoalsManager.ts b/api/src/tv-modules/goals/GoalsManager.ts index c138a2b..149680b 100644 --- a/api/src/tv-modules/goals/GoalsManager.ts +++ b/api/src/tv-modules/goals/GoalsManager.ts @@ -63,8 +63,16 @@ export default class GoalsManager { return new GoalItemForClient(goal, (await this.getPermissionsForGoal(goal.id)).getAllPermissions()); } + private async isInboxGoal(goalId: number): Promise { + const goal = await this.goalsRepository.findGoalById(goalId); + return !!goal && goal.isInbox; + } + /** @deprecated use deleteGoalNew instead */ async deleteGoal(goalId: number): Promise { + if (await this.isInboxGoal(goalId)) { + return false; + } return await this.goalsRepository.deleteGoal(goalId); } @@ -88,6 +96,9 @@ export default class GoalsManager { } async updateArchive(goalId: number, archive: GoalItemInDb['archive']) { + if (archive === 1 && (await this.isInboxGoal(goalId))) { + return false; + } return await this.goalsRepository.updateArchive(goalId, archive); } @@ -134,6 +145,12 @@ export default class GoalsManager { } async updateGoalNew(goalData: GoalsArgUpdate): Promise { + // The Inbox must never be archived. Archiving flows through this endpoint + // (PATCH /module/goals), not the unrouted updateArchive, so the guard lives here. + if (goalData.archive === 1 && (await this.isInboxGoal(goalData.id))) { + return false; + } + const goal = await this.goalsRepository.updateGoalNew(goalData); if (!goal) { @@ -153,6 +170,9 @@ export default class GoalsManager { } async deleteGoalNew(goalData: GoalsArgDelete) { + if (await this.isInboxGoal(goalData.goalId)) { + return false; + } return await this.goalsRepository.deleteGoalNew(goalData); } diff --git a/api/src/tv-modules/goals/GoalsRepository.ts b/api/src/tv-modules/goals/GoalsRepository.ts index f2782cf..3ecbb19 100644 --- a/api/src/tv-modules/goals/GoalsRepository.ts +++ b/api/src/tv-modules/goals/GoalsRepository.ts @@ -12,7 +12,7 @@ import type { AddGoalToDbArg, GoalItemInDb, GoalItemsInDb, UpdateGoalDbArg } fro import { logError } from '../../utils/api'; import { updateQuery } from '../../utils/db-helper'; import { callWithCatch } from '../../utils/helpers'; -import type { GoalsArgAdd, GoalsArgDelete, GoalsArgUpdate } from './types'; +import type { GoalsArgAdd, GoalsArgCreateInbox, GoalsArgDelete, GoalsArgUpdate } from './types'; export class GoalsRepository { private readonly db: Database; @@ -157,6 +157,7 @@ export class GoalsRepository { backlogVersion: GoalsSchema.backlogVersion, organizationId: GoalsSchema.organizationId, estimateUnit: GoalsSchema.estimateUnit, + isInbox: GoalsSchema.isInbox, }) .from(GoalsSchema) .leftJoin(CollaborationUsersToGoalsSchema, eq(GoalsSchema.id, CollaborationUsersToGoalsSchema.goalId)) @@ -232,9 +233,56 @@ export class GoalsRepository { return result[0]; } - async updateGoalNew(goalData: GoalsArgUpdate): Promise { + async findInboxGoal(organizationId: number): Promise { const result = await callWithCatch(() => - this.db.dbDrizzle.update(GoalsSchema).set(goalData).where(eq(GoalsSchema.id, goalData.id)).returning() + this.db.dbDrizzle + .select() + .from(GoalsSchema) + .where(and(eq(GoalsSchema.organizationId, organizationId), eq(GoalsSchema.isInbox, true))) + ); + if (!result || result.length === 0) return false; + return result[0]; + } + + async createInboxGoal(args: GoalsArgCreateInbox): Promise { + const existing = await this.findInboxGoal(args.organizationId); + if (existing) { + return existing; + } + + const result = await callWithCatch(() => + this.db.dbDrizzle + .insert(GoalsSchema) + .values({ + name: 'Inbox', + owner: args.ownerId, + organizationId: args.organizationId, + isInbox: true, + }) + .returning() + ); + + if (!result) { + return false; + } + + return result[0]; + } + + async updateGoalNew(goalData: GoalsArgUpdate): Promise { + const updates: Partial = {}; + if (goalData.name !== undefined) updates.name = goalData.name; + if (goalData.description !== undefined) updates.description = goalData.description; + if (goalData.color !== undefined) updates.color = goalData.color; + if (goalData.estimateUnit !== undefined) updates.estimateUnit = goalData.estimateUnit; + if (goalData.archive !== undefined) updates.archive = goalData.archive; + + if (Object.keys(updates).length === 0) { + return this.findGoalById(goalData.id); + } + + const result = await callWithCatch(() => + this.db.dbDrizzle.update(GoalsSchema).set(updates).where(eq(GoalsSchema.id, goalData.id)).returning() ); if (!result) { return false; diff --git a/api/src/tv-modules/goals/types.ts b/api/src/tv-modules/goals/types.ts index 0fb88a9..754f58b 100644 --- a/api/src/tv-modules/goals/types.ts +++ b/api/src/tv-modules/goals/types.ts @@ -17,6 +17,7 @@ export const GoalsArkTypeUpdate = type({ 'description?': 'string | null', 'color?': 'string | null', "estimateUnit?": "'hours' | 'points'", + 'archive?': '0 | 1', }); export type GoalsArgUpdate = typeof GoalsArkTypeUpdate.infer; @@ -34,3 +35,8 @@ export const GoalsArkTypeFetch = type({ export type GoalsArgFetch = typeof GoalsArkTypeFetch.infer; export type GoalsItemForClientWithPermissions = GoalsSchemaTypeForSelect & { permissions: GoalPermissionsForClient }; + +export type GoalsArgCreateInbox = { + ownerId: number; + organizationId: number; +}; diff --git a/api/src/tv-modules/sso/SsoController.ts b/api/src/tv-modules/sso/SsoController.ts index 8a6451b..6053bd6 100644 --- a/api/src/tv-modules/sso/SsoController.ts +++ b/api/src/tv-modules/sso/SsoController.ts @@ -6,6 +6,7 @@ import { $logger } from '../../modules/logget' import { logError } from '../../utils/api' import { generateString, isEmail } from '../../utils/helpers' import AuthModel from '../auth/AuthModel' +import { GoalsRepository } from '../goals/GoalsRepository' import { OrganizationRepository } from '../organizations/OrganizationRepository' import { createSsoProvider } from './providers/provider-factory' import { SsoRepository } from './SsoRepository' @@ -17,6 +18,7 @@ export class SsoController { private readonly ssoRepo = new SsoRepository() private readonly authModel = new AuthModel() private readonly orgRepo = new OrganizationRepository() + private readonly goalsRepo = new GoalsRepository() initiateLogin = async (req: Request, res: Response) => { const configId = Number(req.params.configId) @@ -75,6 +77,7 @@ export class SsoController { const personalOrg = await this.orgRepo.create({ name: `${login}'s workspace`, slug: personalOrgSlug }, id, true) if (personalOrg) { await this.orgRepo.addMember(personalOrg.id, ssoResult.email, 'owner') + await this.goalsRepo.createInboxGoal({ ownerId: id, organizationId: personalOrg.id }) } userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email)) diff --git a/package.json b/package.json index 48fc115..8401522 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "taskview-ce-monorepo", - "version": "1.48.4", + "version": "1.48.7", "private": true, "description": "TaskView CE monorepo containing web, API, and packages", "workspaces": [ @@ -44,4 +44,4 @@ "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0" } -} \ No newline at end of file +} diff --git a/taskview-packages/taskview-api/src/api/goals.types.ts b/taskview-packages/taskview-api/src/api/goals.types.ts index 016af5f..ec35dcc 100644 --- a/taskview-packages/taskview-api/src/api/goals.types.ts +++ b/taskview-packages/taskview-api/src/api/goals.types.ts @@ -44,6 +44,7 @@ export type GoalItem = { dateCreation: string | null; organizationId: number | null; estimateUnit: 'hours' | 'points'; + isInbox: boolean; }; export type GoalArgItemAdd = Pick & Partial>; diff --git a/taskview-packages/taskview-db-schemas/src/schemas/goals.schema.ts b/taskview-packages/taskview-db-schemas/src/schemas/goals.schema.ts index 4922395..18f8b5a 100644 --- a/taskview-packages/taskview-db-schemas/src/schemas/goals.schema.ts +++ b/taskview-packages/taskview-db-schemas/src/schemas/goals.schema.ts @@ -1,4 +1,4 @@ -import { integer, pgSchema, timestamp, varchar } from "drizzle-orm/pg-core"; +import { boolean, integer, pgSchema, timestamp, varchar } from "drizzle-orm/pg-core"; import { UsersSchema } from "./users.schema"; import { OrganizationsSchema } from "./organizations.schema"; // import { createInsertSchema, createSelectSchema } from "drizzle-arktype"; @@ -16,6 +16,7 @@ export const GoalsSchema = pgSchema('tasks').table('goals', { backlogVersion: integer('backlog_version').default(1), organizationId: integer('organization_id').references(() => OrganizationsSchema.id, { onDelete: 'cascade' }), estimateUnit: varchar('estimate_unit', { length: 10 }).$type<'hours' | 'points'>().notNull().default('points'), + isInbox: boolean('is_inbox').notNull().default(false), }); export type GoalsSchemaTypeForSelect = typeof GoalsSchema.$inferSelect; diff --git a/taskview-plugin/.claude-plugin/plugin.json b/taskview-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..56f7e77 --- /dev/null +++ b/taskview-plugin/.claude-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "taskview", + "displayName": "TaskView", + "version": "0.1.0", + "description": "Manage TaskView projects and tasks from Claude Code — browse projects, view and create tasks, and log your work without leaving the terminal. Bundles the TaskView MCP server.", + "author": { + "name": "Nikolai Giman" + }, + "userConfig": { + "taskview_url": { + "type": "string", + "title": "TaskView API URL", + "description": "TaskView Cloud is https://api.taskview.tech — or your own self-hosted instance URL (e.g. https://taskview.yourcompany.com).", + "required": true + }, + "taskview_token": { + "type": "string", + "title": "TaskView API token", + "description": "An API token (tvk_...) generated in your TaskView account settings. Scope it to the minimum permissions and projects the assistant should be able to reach.", + "sensitive": true, + "required": true + } + } +} diff --git a/taskview-plugin/.mcp.json b/taskview-plugin/.mcp.json new file mode 100644 index 0000000..324799f --- /dev/null +++ b/taskview-plugin/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "taskview": { + "command": "npx", + "args": ["-y", "taskview-mcp"], + "env": { + "TASKVIEW_URL": "${user_config.taskview_url}", + "TASKVIEW_TOKEN": "${user_config.taskview_token}" + } + } + } +} diff --git a/taskview-plugin/README.md b/taskview-plugin/README.md new file mode 100644 index 0000000..fe696c4 --- /dev/null +++ b/taskview-plugin/README.md @@ -0,0 +1,38 @@ +# TaskView plugin for Claude Code + +Browse and manage your [TaskView](https://taskview.tech) projects and tasks directly from Claude Code. The plugin bundles the [`taskview-mcp`](https://www.npmjs.com/package/taskview-mcp) server plus skills and slash commands, so setup is just "install and paste a token" — no manual config editing. + +## What you get + +- **Bundled MCP server** — the full TaskView tool surface (projects, lists, tasks, tags, kanban, collaboration, dependencies, notifications), no separate install. +- **Slash commands:** + - `/taskview-projects` — list your projects + - `/taskview-tasks [project]` — show tasks in a project + - `/taskview-new-task ` — create a task + - `/taskview-log [project]` — log the work done this session as a task +- **Skill** — conventions so the assistant resolves ids correctly, paginates, and logs completed work as top-level tasks. + +## Requirements + +- Node.js >= 24 (the bundled MCP server runs via `npx`) +- A TaskView API token (`tvk_...`) from your account settings — see [API tokens](https://taskview.tech/docs/features/api-tokens) + +## Install + +```text +/plugin marketplace add Gimanh/taskview-community +/plugin install taskview@taskview +``` + +On install you'll be prompted for two values: + +| Setting | Example | Notes | +|---|---|---| +| **TaskView API URL** | `https://api.taskview.tech` | TaskView Cloud, or your self-hosted instance URL | +| **TaskView API token** | `tvk_...` | Stored securely in your system keychain. Scope it to the minimum permissions/projects needed. | + +That's it — the `taskview` MCP server and the commands become available after the plugin is enabled. + +## Security + +The token is supplied via the plugin's secure `userConfig` (marked sensitive → stored in the OS keychain, not in plaintext settings) and passed to the MCP server only as an environment variable. Every tool call still goes through the full TaskView API stack — authentication, permission checks, and token-scope intersection. Give the token only the scope the assistant should have. diff --git a/taskview-plugin/commands/taskview-log.md b/taskview-plugin/commands/taskview-log.md new file mode 100644 index 0000000..61fc699 --- /dev/null +++ b/taskview-plugin/commands/taskview-log.md @@ -0,0 +1,13 @@ +--- +description: Log the work done in this session as a TaskView task +--- + +Record what was accomplished in the current Claude Code session as a TaskView task, using the `taskview` MCP tools. + +1. Summarize the concrete work completed in this session in 1–3 short lines. +2. Ask the user which project to log it under (or use the one they name in $ARGUMENTS), and resolve its id with `list_goals`. +3. Create **one top-level task** with `create_task` — description = the summary. Set priority/deadline only if the user asks. +4. Show the created task and ask whether to break it into subtasks or add tags. +5. Add task note if needed. + +Confirm the project before creating. Do not create the task in a guessed project, and do not create duplicates — check `list_tasks` if unsure. diff --git a/taskview-plugin/commands/taskview-new-task.md b/taskview-plugin/commands/taskview-new-task.md new file mode 100644 index 0000000..a38bbf5 --- /dev/null +++ b/taskview-plugin/commands/taskview-new-task.md @@ -0,0 +1,14 @@ +--- +description: Create a task in TaskView +--- + +Create a TaskView task using the `taskview` MCP tools. + +Request: $ARGUMENTS + +1. Parse the request for: task description, target project, optional list, priority (1=low, 2=medium, 3=high), and deadline. +2. Resolve the project id with `list_goals` (and the list id with `list_lists` if a list is named). If the project is missing or ambiguous, ask — do not guess. +3. Call `create_task` with the resolved ids and parsed fields. +4. Confirm what was created (project, description, priority, deadline) and offer to add tags or assignees. + +Never create a task in a guessed/unconfirmed project. diff --git a/taskview-plugin/commands/taskview-projects.md b/taskview-plugin/commands/taskview-projects.md new file mode 100644 index 0000000..c886501 --- /dev/null +++ b/taskview-plugin/commands/taskview-projects.md @@ -0,0 +1,11 @@ +--- +description: List your TaskView projects (goals) +--- + +List the user's TaskView projects using the `taskview` MCP tools. + +1. If the user has more than one organization, resolve it with `list_organizations` and ask which one if unclear. +2. Call `list_goals` and present the projects as a short list: name, and a hint of activity if available. +3. Offer next actions — e.g. "view tasks in a project" (`/taskview-tasks`) or "create a task" (`/taskview-new-task`). + +Do not invent project names or ids — only show what the tools return. diff --git a/taskview-plugin/commands/taskview-tasks.md b/taskview-plugin/commands/taskview-tasks.md new file mode 100644 index 0000000..85db364 --- /dev/null +++ b/taskview-plugin/commands/taskview-tasks.md @@ -0,0 +1,14 @@ +--- +description: Show tasks in a TaskView project +--- + +Show the user's TaskView tasks using the `taskview` MCP tools. + +Arguments (optional): a project name to scope to — $ARGUMENTS + +1. Resolve the project: call `list_goals` and match $ARGUMENTS to a project. If empty or ambiguous, ask which project. +2. Optionally scope to a list with `list_lists` + the `componentId` if the user names one. +3. Call `list_tasks` (0-based `page`, ~30/page) — page through until a page returns fewer than 30. Pass `showCompleted` only if the user wants completed tasks. Use `sortBy` with `descending` when ordering by date or priority. +4. Present a concise checklist: status, description, priority (1=low/2=med/3=high), deadline. + +Resolve every id first — never guess project/list ids. diff --git a/taskview-plugin/skills/taskview/SKILL.md b/taskview-plugin/skills/taskview/SKILL.md new file mode 100644 index 0000000..2bfb307 --- /dev/null +++ b/taskview-plugin/skills/taskview/SKILL.md @@ -0,0 +1,51 @@ +--- +name: taskview +description: Conventions for browsing and managing TaskView projects, lists, and tasks through the bundled taskview MCP tools +--- + +# Working with TaskView + +This plugin connects Claude Code to a TaskView instance through the `taskview` +MCP server. Use it to let the user view and manage their work without leaving +the terminal. + +## Terminology + +- A **goal** IS a **project** — the words are interchangeable. Every tool/param + that says "goal" (`goalId`, `list_goals`, `create_goal`) means a project. When + the user says "project", use the `*_goal` tools. + +## Data model (top → bottom) + +- **Organization** — workspace grouping projects and members. +- **Project (goal)** — `goalId`; `list_goals` / `create_goal` / `update_goal`. +- **List (component)** — a section inside a project; pass `componentId` to + `list_tasks` to scope to one list, omit it to see all tasks. +- **Task** — unit of work; supports subtasks, assignees, tags, priority + (1=low, 2=medium, 3=high), deadlines, and dependencies. + +## Rules that prevent mistakes + +- **Resolve IDs first, never guess them.** Map a name to its numeric id with the + matching `list_*` tool (project → `list_goals`, list → `list_lists`, + members → `list_collaborators_for_goal`, columns → `list_kanban_columns`, + tags → `list_tags`), then pass that id to create/update/delete tools. +- **`list_tasks` is paginated** — `page` is 0-based (~30/page). Keep requesting + the next page until one returns fewer than 30. Completed tasks are hidden + unless `showCompleted` is set. Use `sortBy` (`date` | `priority`) with + `descending` to order. +- **Ask before destructive actions** (`delete_*`) and before creating duplicates + — check with a `list_*` call first. + +## Presenting results + +- Show tasks as a concise checklist: status, description, priority, deadline. +- When something is ambiguous (which project/list/org), ask one short question + rather than guessing. + +## Logging completed work + +When the user asks to record what was done, create **one top-level task per +finished unit of work** in the relevant project (resolve it first), with a clear +description and, if known, the list and priority. Do not bury the summary as a +subtask unless the user asks for that. diff --git a/web/package.json b/web/package.json index 37b690c..797c406 100644 --- a/web/package.json +++ b/web/package.json @@ -2,7 +2,7 @@ "name": "web-nuxt-ui", "private": true, "type": "module", - "version": "1.48.4", + "version": "1.48.7", "scripts": { "dev": "vite", "build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build", @@ -91,4 +91,4 @@ "vue-tsc": "^3.2.2" }, "packageManager": "pnpm@10.28.1" -} \ No newline at end of file +} diff --git a/web/src/components/features/main/screen-main/parts/MainScreenAddTaskDialog.vue b/web/src/components/features/main/screen-main/parts/MainScreenAddTaskDialog.vue index 6d77348..06021bc 100644 --- a/web/src/components/features/main/screen-main/parts/MainScreenAddTaskDialog.vue +++ b/web/src/components/features/main/screen-main/parts/MainScreenAddTaskDialog.vue @@ -105,6 +105,7 @@ import { useGoalsStore } from '@/stores/goals.store' import { useBaseScreenStore } from '@/stores/base-screen.store' import { $ls, $tvApi } from '@/plugins/axios' import { useTaskView } from '@/composables/useTaskView' +import { useInbox } from '@/composables/useInbox' import { useWeekStart } from '@/composables/useWeekStart' const weekStart = useWeekStart() @@ -136,6 +137,7 @@ const { t } = useI18n() const goalsStore = useGoalsStore() const baseScreenStore = useBaseScreenStore() const { isMobile } = useTaskView() +const { inbox } = useInbox() const taskDescription = ref(props.taskName) const selectedGoalId = ref(undefined) @@ -161,18 +163,13 @@ watch(model, async (isOpen) => { ) } - // Pre-select the last used project. Read fresh from storage on every open so - // it stays in sync across the multiple dialog instances on the screen. - const activeGoals = goalsStore.goals.filter(g => !g.archive) + // Use the last chosen project (it may be the Inbox); otherwise default to the + // Inbox — that's exactly what it's for. const saved = await $ls.getValue(LAST_SELECTED_PROJECT_KEY) const savedId = saved ? Number(saved) : undefined - const savedGoal = savedId ? activeGoals.find(g => g.id === savedId) : undefined + const savedGoal = savedId ? goalsStore.goals.find(g => !g.archive && g.id === savedId) : undefined - if (savedGoal) { - selectedGoalId.value = savedGoal.id - } else if (activeGoals.length === 1) { - selectedGoalId.value = activeGoals[0].id - } + selectedGoalId.value = savedGoal ? savedGoal.id : inbox.value?.id } else { // Reset form on close taskDescription.value = props.taskName @@ -181,14 +178,16 @@ watch(model, async (isOpen) => { } }) -const goalOptions = computed(() => - goalsStore.goals - .filter(g => !g.archive) - .map(g => ({ - label: g.name, - value: g.id, - })), -) +const goalOptions = computed(() => { + const active = goalsStore.goals.filter(g => !g.archive) + const options = active + .filter(g => !g.isInbox) + .map(g => ({ label: g.name, value: g.id })) + if (inbox.value) { + options.unshift({ label: t('projects.inbox'), value: inbox.value.id }) + } + return options +}) const formattedDeadline = computed(() => { if (!deadline.value) return '' diff --git a/web/src/components/features/main/screen-main/parts/NoGoals.vue b/web/src/components/features/main/screen-main/parts/NoGoals.vue index 5fc1b85..1f5e11b 100644 --- a/web/src/components/features/main/screen-main/parts/NoGoals.vue +++ b/web/src/components/features/main/screen-main/parts/NoGoals.vue @@ -22,7 +22,7 @@ import { useI18n } from 'vue-i18n' const goalsStore = useGoalsStore() const { t } = useI18n() -const noGoals = computed(() => goalsStore.goals.filter((g) => !g.archive).length === 0) +const noGoals = computed(() => goalsStore.goals.filter((g) => !g.archive && !g.isInbox).length === 0) async function addProject(name: string) { await goalsStore.addGoal({ name }) diff --git a/web/src/components/features/projects/ProjectsSidebar.vue b/web/src/components/features/projects/ProjectsSidebar.vue index 0928b13..51df128 100644 --- a/web/src/components/features/projects/ProjectsSidebar.vue +++ b/web/src/components/features/projects/ProjectsSidebar.vue @@ -33,8 +33,8 @@ const goalsStore = useGoalsStore() const orgStore = useOrganizationStore() const { goals } = storeToRefs(goalsStore) -const activeProjects = computed(() => goals.value.filter(p => p.archive === 0)) -const archivedProjects = computed(() => goals.value.filter(p => p.archive === 1)) +const activeProjects = computed(() => goals.value.filter(p => p.archive === 0 && !p.isInbox)) +const archivedProjects = computed(() => goals.value.filter(p => p.archive === 1 && !p.isInbox)) watch(() => orgStore.currentOrg, async () => { diff --git a/web/src/components/features/tasks/TvTaskDetailOverlay.vue b/web/src/components/features/tasks/TvTaskDetailOverlay.vue index a44485a..ec38b71 100644 --- a/web/src/components/features/tasks/TvTaskDetailOverlay.vue +++ b/web/src/components/features/tasks/TvTaskDetailOverlay.vue @@ -23,6 +23,22 @@ + diff --git a/web/src/components/sidebars/DashboardSidebarFirst.vue b/web/src/components/sidebars/DashboardSidebarFirst.vue index 7873b1c..4b6e152 100644 --- a/web/src/components/sidebars/DashboardSidebarFirst.vue +++ b/web/src/components/sidebars/DashboardSidebarFirst.vue @@ -19,6 +19,10 @@ + + + + + + diff --git a/web/src/components/sidebars/dashboard-second/SidebarProjectSelect.vue b/web/src/components/sidebars/dashboard-second/SidebarProjectSelect.vue index 7914b93..cbe7c70 100644 --- a/web/src/components/sidebars/dashboard-second/SidebarProjectSelect.vue +++ b/web/src/components/sidebars/dashboard-second/SidebarProjectSelect.vue @@ -10,7 +10,7 @@ variant="soft" size="xl" block - icon="i-lucide-folder" + :icon="currentProject?.isInbox ? 'i-lucide-inbox' : 'i-lucide-folder'" trailing-icon="i-lucide-chevron-down" :ui="{ base: 'rounded-xl', trailingIcon: 'ms-auto' }" > @@ -18,7 +18,7 @@ class="flex-1 text-left truncate" :class="currentProject ? '' : 'text-muted'" > - {{ currentProject?.name ?? t('projects.selectProject') }} + {{ currentProjectLabel }} @@ -89,7 +89,7 @@ class="flex items-center justify-end gap-2" > Number(route.params.projectId) || null) -const activeProjects = computed(() => goals.value.filter(p => p.archive === 0)) -const archivedProjects = computed(() => goals.value.filter(p => p.archive === 1)) +const activeProjects = computed(() => goals.value.filter(p => p.archive === 0 && !p.isInbox)) +const archivedProjects = computed(() => goals.value.filter(p => p.archive === 1 && !p.isInbox)) const currentProject = computed(() => currentProjectId.value ? goalsStore.goalMap.get(currentProjectId.value) ?? null : null, ) const isArchived = computed(() => currentProject.value?.archive === 1) +const isInbox = computed(() => currentProject.value?.isInbox === true) +const currentProjectLabel = computed(() => + currentProject.value + ? (currentProject.value.isInbox ? t('projects.inbox') : currentProject.value.name) + : t('projects.selectProject'), +) const { canEditGoal, canDeleteGoal } = useGoalPermissionsFor(currentProject) diff --git a/web/src/composables/useInbox.ts b/web/src/composables/useInbox.ts new file mode 100644 index 0000000..6e1a271 --- /dev/null +++ b/web/src/composables/useInbox.ts @@ -0,0 +1,13 @@ +import { computed } from 'vue' +import { storeToRefs } from 'pinia' +import { useGoalsStore } from '@/stores/goals.store' +import type { Project } from '@/components/features/projects/types' + +export const useInbox = () => { + const goalsStore = useGoalsStore() + const { goals } = storeToRefs(goalsStore) + + const inbox = computed(() => goals.value.find(g => g.isInbox) ?? null) + + return { inbox } +} diff --git a/web/src/composables/useTaskView.ts b/web/src/composables/useTaskView.ts index 180890f..62f0dad 100644 --- a/web/src/composables/useTaskView.ts +++ b/web/src/composables/useTaskView.ts @@ -13,7 +13,7 @@ export const useTaskView = () => { const isFullscreenModal = bp.smallerOrEqual('fullscreenModalMax') return { - hasActiveGoals: computed(() => goalsStore.goals.length > 0), + hasActiveGoals: computed(() => goalsStore.goals.some(g => !g.isInbox)), isMobile, isDesktop, isFullscreenModal, diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 5d1b77b..339599b 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -103,6 +103,7 @@ export default { createNew: 'Create new project', add: 'Add project', empty: 'No projects yet', + inbox: 'Inbox', }, contextMenu: { tasks: 'Tasks', diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 8c659b1..ce70a98 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -103,6 +103,7 @@ export default { createNew: 'Crear nuevo proyecto', add: 'Añadir proyecto', empty: 'Aún no hay proyectos', + inbox: 'Bandeja de entrada', }, contextMenu: { tasks: 'Lista', diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index daa6088..8fd50e2 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -103,6 +103,7 @@ export default { createNew: 'Создать новый проект', add: 'Добавить проект', empty: 'Пока нет проектов', + inbox: 'Входящие', }, contextMenu: { tasks: 'Список', diff --git a/web/src/types/goals.types.ts b/web/src/types/goals.types.ts index 9b99326..99ec21a 100644 --- a/web/src/types/goals.types.ts +++ b/web/src/types/goals.types.ts @@ -12,6 +12,7 @@ export const DEFAULT_GOAL_ITEM: GoalItem = { dateCreation: null, organizationId: null, estimateUnit: 'points', + isInbox: false, } export const AllGoalPermissions = TvPermissions diff --git a/web/vite.config.ts b/web/vite.config.ts index a9b240f..d63e899 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -182,7 +182,7 @@ export default defineConfig({ variant: { taskview: { root: 'cursor-pointer shadow-sm bg-tv-ui-bg-elevated', - container: 'sm:p-2 sm:px-4', + container: 'p-2 px-4 sm:p-2 sm:px-4', }, },