Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1739-task-status-one-source.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Inbox no longer shows a task that was never started as "In progress", and no longer ages it from its creation: the Inbox and the Board now read one task row, so the status label and the counter agree and a card's age comes from the moment the row started.
111 changes: 59 additions & 52 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -7707,6 +7707,56 @@ body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a}
@media (prefers-reduced-motion:reduce){.bt-mw-row{animation:none}}
`;

// src/shared/task-board.js
var COLUMNS = ["not-started", "in-progress", "done"];
var PRIORITIES = ["urgent", "high", "normal", "low"];
var COLUMN_OF = {
open: "not-started",
claimed: "not-started",
working: "in-progress",
blocked: "in-progress",
"result-ready": "in-progress",
accepted: "done",
cancelled: "done",
failed: "done"
};
function columnOf(task) {
return COLUMN_OF[task?.status] ?? "not-started";
}
function priorityOf(task) {
return PRIORITIES.includes(task?.priority) ? task.priority : "normal";
}
var PRIORITY_RANK = { urgent: 0, high: 1, normal: 2, low: 3 };
function workBucket(task) {
if (task?.status === "open" || task?.status === "claimed" || task?.status === "working") return 0;
if (task?.status === "blocked" || task?.status === "result-ready") return 1;
return 2;
}
function compareForWork(a, b) {
const byBucket = workBucket(a) - workBucket(b);
if (byBucket !== 0) return byBucket;
const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2);
if (byPriority !== 0) return byPriority;
const byWorking = (a?.status === "working" ? 0 : 1) - (b?.status === "working" ? 0 : 1);
if (byWorking !== 0) return byWorking;
return (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0);
}
function sameRole(a, b) {
const left = String(a ?? "").trim().toLowerCase();
const right = String(b ?? "").trim().toLowerCase();
return left !== "" && left === right;
}
function compareForQueue(a, b) {
const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2);
if (byPriority !== 0) return byPriority;
return (a?.createdAt ?? 0) - (b?.createdAt ?? 0);
}
function progressOf(task) {
const value = task?.progress;
if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0;
return Math.max(0, Math.min(100, Math.round(value)));
}

// src/client/inbox.js
var import_react13 = require("react");
var KEEP_MS = 7 * 864e5;
Expand Down Expand Up @@ -7975,8 +8025,11 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, workflows = [], n
if (fresh(task.updatedAt)) needs.push({ ...card, status: "failed", reason: lastWhy(task) });
} else if (task.status === "result-ready") needs.push({ ...card, status: "result", reason: task.result?.summary ?? "" });
else if (task.status === "blocked") needs.push({ ...card, status: task.waitingFor === "input" ? "input" : "approval", reason: task.inputRequest?.question ?? "" });
else if (task.status === "open" || task.status === "claimed" || task.status === "working") progress.push({ ...card, status: "task", since: task.createdAt });
else if ((task.status === "accepted" || task.status === "cancelled") && fresh(task.updatedAt)) done.push({ ...card, status: task.status === "accepted" ? "done" : "cancelled" });
else {
const column = columnOf(task);
if (column === "in-progress") progress.push({ ...card, status: "task", since: startedAtOf(task) });
else if (column === "done" && fresh(task.updatedAt)) done.push({ ...card, status: task.status === "cancelled" ? "cancelled" : "done" });
}
}
for (const graph of Array.isArray(workflows) ? workflows : []) {
if (graph === null || typeof graph !== "object") continue;
Expand Down Expand Up @@ -8027,6 +8080,10 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, workflows = [], n
return { needs: needs.sort(newest), progress: progress.sort(newest), done: done.sort(newest).slice(0, DONE_MAX) };
}
var needsCount = (roster, work, tasks, now, workflows = []) => inboxModel({ roster, work, tasks, now, workflows }).needs.length;
function startedAtOf(task) {
const started = (task.actors ?? []).filter((actor) => actor.status === "working" && typeof actor.at === "number");
return started.length ? started.at(-1).at : task.updatedAt;
}
function visibleNeeds(model, answered) {
return { ...model, needs: model.needs.filter((card) => !answered?.[card.id]) };
}
Expand Down Expand Up @@ -8406,56 +8463,6 @@ var INBOX_CSS = `
.bt-inbox-actions .bt-send{height:28px;font-size:12px}
`;

// src/shared/task-board.js
var COLUMNS = ["not-started", "in-progress", "done"];
var PRIORITIES = ["urgent", "high", "normal", "low"];
var COLUMN_OF = {
open: "not-started",
claimed: "not-started",
working: "in-progress",
blocked: "in-progress",
"result-ready": "in-progress",
accepted: "done",
cancelled: "done",
failed: "done"
};
function columnOf(task) {
return COLUMN_OF[task?.status] ?? "not-started";
}
function priorityOf(task) {
return PRIORITIES.includes(task?.priority) ? task.priority : "normal";
}
var PRIORITY_RANK = { urgent: 0, high: 1, normal: 2, low: 3 };
function workBucket(task) {
if (task?.status === "open" || task?.status === "claimed" || task?.status === "working") return 0;
if (task?.status === "blocked" || task?.status === "result-ready") return 1;
return 2;
}
function compareForWork(a, b) {
const byBucket = workBucket(a) - workBucket(b);
if (byBucket !== 0) return byBucket;
const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2);
if (byPriority !== 0) return byPriority;
const byWorking = (a?.status === "working" ? 0 : 1) - (b?.status === "working" ? 0 : 1);
if (byWorking !== 0) return byWorking;
return (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0);
}
function sameRole(a, b) {
const left = String(a ?? "").trim().toLowerCase();
const right = String(b ?? "").trim().toLowerCase();
return left !== "" && left === right;
}
function compareForQueue(a, b) {
const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2);
if (byPriority !== 0) return byPriority;
return (a?.createdAt ?? 0) - (b?.createdAt ?? 0);
}
function progressOf(task) {
const value = task?.progress;
if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0;
return Math.max(0, Math.min(100, Math.round(value)));
}

// src/shared/board-card.js
function taskCardKey(id) {
const text2 = String(id ?? "").trim();
Expand Down
17 changes: 15 additions & 2 deletions core/src/client/inbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { alwaysChoices } from './member-status.js'
import { botNames, currentPart, previewOf, relativeShort } from './text.js'
import { hostText, t } from './i18n.js'
import { failureSentence } from '../shared/failure-copy.js'
import { columnOf } from '../shared/task-board.js'

// Done (and a failed task) ages out after 7 days.
export const KEEP_MS = 7 * 86_400_000
Expand Down Expand Up @@ -292,8 +293,13 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, workflows
if (task.status === 'failed') { if (fresh(task.updatedAt)) needs.push({ ...card, status: 'failed', reason: lastWhy(task) }) }
else if (task.status === 'result-ready') needs.push({ ...card, status: 'result', reason: task.result?.summary ?? '' })
else if (task.status === 'blocked') needs.push({ ...card, status: task.waitingFor === 'input' ? 'input' : 'approval', reason: task.inputRequest?.question ?? '' })
else if (task.status === 'open' || task.status === 'claimed' || task.status === 'working') progress.push({ ...card, status: 'task', since: task.createdAt })
else if ((task.status === 'accepted' || task.status === 'cancelled') && fresh(task.updatedAt)) done.push({ ...card, status: task.status === 'accepted' ? 'done' : 'cancelled' })
else {
// One mapping with the board (#1739): the column is the task row's own status, so the
// Inbox never gives a row a state the board does not give it.
const column = columnOf(task)
if (column === 'in-progress') progress.push({ ...card, status: 'task', since: startedAtOf(task) })
else if (column === 'done' && fresh(task.updatedAt)) done.push({ ...card, status: task.status === 'cancelled' ? 'cancelled' : 'done' })
}
}
// A workflow run that failed (#1706). The plugin keeps that failure in its own
// state, so the Inbox reads it from the same pack the Workflows page gets. One
Expand Down Expand Up @@ -358,6 +364,13 @@ export const needsCount = (roster, work, tasks, now, workflows = []) => inboxMod

// Cards the owner already answered leave the Needs you list. The dialog keeps that
// set until the next roster poll drops the underlying record.
// A task card's age is the row's own clock (#1739): the moment the row entered working,
// never the wall clock and never its creation, so a row that never started carries no age.
function startedAtOf(task) {
const started = (task.actors ?? []).filter(actor => actor.status === 'working' && typeof actor.at === 'number')
return started.length ? started.at(-1).at : task.updatedAt
}

export function visibleNeeds(model, answered) {
return { ...model, needs: model.needs.filter(card => !answered?.[card.id]) }
}
Expand Down
86 changes: 86 additions & 0 deletions core/test/inbox-task-status.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
import { build } from 'esbuild'
import { columnOf } from '../src/shared/task-board.js'

// #1739: the Inbox and the Board must read one task row the same way. The row's status is the
// only source; neither view derives a state from session or turn recency, and a card's age is
// the row's own started-at, never its creation — a row that never started has no age.
const bundle = await build({
entryPoints: [fileURLToPath(new URL('../src/client/inbox.js', import.meta.url))],
bundle: true, format: 'cjs', platform: 'node', write: false,
external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'],
})
const module = { exports: {} }
runInNewContext(bundle.outputFiles[0].text, {
module, exports: module.exports, setTimeout: () => 0, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, Date, Promise,
require: (name) => {
const platform = {
react: {
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}],
useEffect: () => {}, useLayoutEffect: () => {}, useRef: current => ({ current }), useCallback: fn => fn, useMemo: fn => fn(), Fragment: 'fragment',
},
'react-dom': {},
'@deepseek-ai/dsh-client-ui-primitives': {},
}
if (!(name in platform)) throw new Error(`no platform module ${name}`)
return platform[name]
},
})
const { inboxModel } = module.exports

const NOW = Date.UTC(2026, 10, 29, 23, 0, 0)
const HOUR = 3_600_000
const roster = {
self: { memberId: 'm1' },
bots: [{ id: 'b1', name: 'Chief', ownerId: 'm1', sessionId: 's1' }],
rooms: [],
}
roster.byId = Object.fromEntries(roster.bots.map(bot => [bot.id, bot]))
roster.roomsById = {}
const row = (id, status, extra = {}) => ({
id, status, title: `Task ${id}`, ownerBotId: 'b1', requesterId: 'm1', origin: {},
createdAt: NOW - 63 * HOUR, updatedAt: NOW - 63 * HOUR, actors: [], ...extra,
})
// The reported set: five rows that were never claimed. The board paints them Not started (0/5).
const neverStarted = ['t1', 't2', 't3', 't4', 't5'].map(id => row(id, 'open'))
const claimed = row('t6', 'claimed', { actors: [{ status: 'claimed', at: NOW - 63 * HOUR }] })
const working = row('t7', 'working', {
createdAt: NOW - 70 * HOUR,
updatedAt: NOW - 2 * HOUR,
actors: [{ status: 'claimed', at: NOW - 70 * HOUR }, { status: 'working', at: NOW - 63 * HOUR }],
})
const workingNoLog = row('t8', 'working', { createdAt: NOW - 70 * HOUR, updatedAt: NOW - 3 * HOUR })
const accepted = row('t9', 'accepted', { updatedAt: NOW - HOUR })
const tasks = [...neverStarted, claimed, working, workingNoLog, accepted]
const model = inboxModel({ roster, work: {}, tasks, sessions: { byId: {} }, now: NOW })
const ids = list => Array.from(list, card => String(card.id))

test('a row the board paints Not started is not an In progress card in the Inbox (#1739)', () => {
// The reported defect: Board 0/5 Not started while the Inbox showed the same five rows as
// In progress for 63h.
assert.deepEqual(ids(model.progress), ['task:t7', 'task:t8'])
for (const task of [...neverStarted, claimed, accepted]) {
assert.ok(!ids(model.progress).includes(`task:${task.id}`), `${task.id} (${task.status}) must not be In progress`)
}
})

test('both views read the same row the same way: In progress is exactly the board in-progress column', () => {
const boardInProgress = tasks.filter(task => columnOf(task) === 'in-progress').map(task => `task:${task.id}`)
assert.deepEqual(ids(model.progress), boardInProgress)
const boardNotStarted = tasks.filter(task => columnOf(task) === 'not-started')
assert.equal(boardNotStarted.length, 6)
// The Inbox shows no card at all for those rows, so it can show no age for them either.
assert.deepEqual(ids(model.progress).filter(id => boardNotStarted.some(task => `task:${task.id}` === id)), [])
})

test('a card age comes from the row clock: the moment it started, never its creation', () => {
const card = model.progress.find(entry => entry.id === 'task:t7')
assert.equal(card.since, NOW - 63 * HOUR)
const fallback = model.progress.find(entry => entry.id === 'task:t8')
assert.equal(fallback.since, NOW - 3 * HOUR, 'a working row with no transition log ages from its own updatedAt')
for (const entry of model.progress) assert.ok(typeof entry.since === 'number' && entry.since <= NOW)
})
Loading
Loading