plc-cli 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +695 -114
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { Command } from "commander";
6
6
  // src/meta.ts
7
7
  var PRODUCT_NAME = "Plane Cockpit";
8
8
  var BINARY_NAME = "plc";
9
- var VERSION = true ? "0.1.3" : "0.0.0-dev";
9
+ var VERSION = true ? "0.3.0" : "0.0.0-dev";
10
10
  var AUTHOR_HANDLE = "@brunoomariano";
11
11
 
12
12
  // src/commands/auth/index.ts
@@ -233,6 +233,12 @@ var profileSchema = z.strictObject({
233
233
  // Profile-wide default sort, inherited by any view that does not declare
234
234
  // its own `sort`. Same shape as a view's sort.
235
235
  sort: sortSpecSchema.optional(),
236
+ // Global state ordering for `sort: state` and the quick-transition
237
+ // navigation. An ordered list of state slugs (matched case-insensitively,
238
+ // whitespace-collapsed): listed states sort first in this order, unlisted
239
+ // ones after by workflow group. Project states are customizable, so this
240
+ // lets one list rank them across every project.
241
+ state_order: z.array(z.string().min(1)).optional(),
236
242
  // Profile-wide default column layout, inherited by views without a layout.
237
243
  layout: layoutSchema.optional()
238
244
  }).optional(),
@@ -252,6 +258,7 @@ var planeConfigSchema = z.strictObject({
252
258
  var DEFAULT_TIMEOUT_MS = 3e4;
253
259
  var DEFAULT_CACHE_TTL_SECONDS = 300;
254
260
  var STATES_LABELS_TTL_SECONDS = 300;
261
+ var ACTIVITIES_TTL_SECONDS = 60;
255
262
  var DEFAULT_CONFIG_PATHS = ["~/.config/plane-cli/config.yaml"];
256
263
 
257
264
  // src/config/load-config.ts
@@ -980,7 +987,11 @@ var cacheKeys = {
980
987
  states: (slug, projectId) => workspaceKey(slug, "project", projectId, "states"),
981
988
  // Labels are project-scoped too, cached per project id.
982
989
  labels: (slug, projectId) => workspaceKey(slug, "project", projectId, "labels"),
983
- issuesPage: (slug, projectId, hash) => workspaceKey(slug, "project", projectId, "issues", hash)
990
+ issuesPage: (slug, projectId, hash) => workspaceKey(slug, "project", projectId, "issues", hash),
991
+ // The activity log is per work item, so it is cached per project + issue id.
992
+ issueActivities: (slug, projectId, issueId) => workspaceKey(slug, "project", projectId, "issue", issueId, "activities"),
993
+ // Relations are per work item too, cached per project + issue id.
994
+ issueRelations: (slug, projectId, issueId) => workspaceKey(slug, "project", projectId, "issue", issueId, "relations")
984
995
  };
985
996
 
986
997
  // src/plane/projects.ts
@@ -1059,6 +1070,31 @@ function filtersFingerprint(filters) {
1059
1070
  return createHash("sha256").update(json).digest("hex").slice(0, 16);
1060
1071
  }
1061
1072
 
1073
+ // src/plane/state-rank.ts
1074
+ var STATE_GROUP_RANK = {
1075
+ backlog: 0,
1076
+ unstarted: 1,
1077
+ started: 2,
1078
+ completed: 3,
1079
+ cancelled: 4
1080
+ };
1081
+ var UNLISTED_OFFSET = 1e6;
1082
+ function normalizeStateSlug(name) {
1083
+ return name.trim().toLowerCase().replace(/\s+/g, " ");
1084
+ }
1085
+ function buildStateRank(stateOrder) {
1086
+ const position = /* @__PURE__ */ new Map();
1087
+ (stateOrder ?? []).forEach((slug, index) => {
1088
+ const normalized = normalizeStateSlug(slug);
1089
+ if (!position.has(normalized)) position.set(normalized, index);
1090
+ });
1091
+ return (state) => {
1092
+ const listed = position.get(normalizeStateSlug(state.name));
1093
+ if (listed !== void 0) return listed;
1094
+ return UNLISTED_OFFSET + STATE_GROUP_RANK[state.group];
1095
+ };
1096
+ }
1097
+
1062
1098
  // src/plane/sort-issues.ts
1063
1099
  var PRIORITY_RANK = {
1064
1100
  urgent: 0,
@@ -1067,45 +1103,41 @@ var PRIORITY_RANK = {
1067
1103
  low: 3,
1068
1104
  none: 4
1069
1105
  };
1070
- var STATE_GROUP_RANK = {
1071
- backlog: 0,
1072
- unstarted: 1,
1073
- started: 2,
1074
- completed: 3,
1075
- cancelled: 4
1076
- };
1077
1106
  var DEFAULT_SORT = [
1078
1107
  { field: "project", direction: "asc" },
1079
1108
  { field: "priority", direction: "desc" },
1080
1109
  { field: "state", direction: "asc" },
1081
1110
  { field: "updated_at", direction: "desc" }
1082
1111
  ];
1083
- var ASC_COMPARATORS = {
1084
- project: (a, b) => a.project_identifier.localeCompare(b.project_identifier),
1085
- priority: (a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority],
1086
- state: (a, b) => STATE_GROUP_RANK[a.state.group] - STATE_GROUP_RANK[b.state.group],
1087
- created_at: (a, b) => a.created_at.localeCompare(b.created_at),
1088
- updated_at: (a, b) => a.updated_at.localeCompare(b.updated_at),
1089
- assign: (a, b) => firstAssignee(a).localeCompare(firstAssignee(b))
1090
- };
1112
+ function ascComparators(stateRank) {
1113
+ return {
1114
+ project: (a, b) => a.project_identifier.localeCompare(b.project_identifier),
1115
+ priority: (a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority],
1116
+ state: (a, b) => stateRank(a.state) - stateRank(b.state),
1117
+ created_at: (a, b) => a.created_at.localeCompare(b.created_at),
1118
+ updated_at: (a, b) => a.updated_at.localeCompare(b.updated_at),
1119
+ assign: (a, b) => firstAssignee(a).localeCompare(firstAssignee(b))
1120
+ };
1121
+ }
1091
1122
  function firstAssignee(issue) {
1092
1123
  return issue.assignees[0]?.display_name ?? "";
1093
1124
  }
1094
- function compareKey(a, b, field, direction) {
1125
+ function compareKey(a, b, { field, direction }, comparators) {
1095
1126
  if (field === "assign") {
1096
1127
  const aUnassigned = a.assignees.length === 0;
1097
1128
  const bUnassigned = b.assignees.length === 0;
1098
1129
  if (aUnassigned !== bUnassigned) return aUnassigned ? 1 : -1;
1099
1130
  if (aUnassigned && bUnassigned) return 0;
1100
1131
  }
1101
- const base = ASC_COMPARATORS[field](a, b);
1132
+ const base = comparators[field](a, b);
1102
1133
  return direction === "desc" ? -base : base;
1103
1134
  }
1104
- function sortIssues(issues, sort) {
1135
+ function sortIssues(issues, sort, stateOrder) {
1105
1136
  if (!sort || sort.length === 0) return issues;
1137
+ const comparators = ascComparators(buildStateRank(stateOrder));
1106
1138
  return [...issues].sort((a, b) => {
1107
- for (const { field, direction } of sort) {
1108
- const cmp = compareKey(a, b, field, direction);
1139
+ for (const key of sort) {
1140
+ const cmp = compareKey(a, b, key, comparators);
1109
1141
  if (cmp !== 0) return cmp;
1110
1142
  }
1111
1143
  return 0;
@@ -1412,18 +1444,21 @@ var IssuesService = class {
1412
1444
  * `defaultsSort` is the profile-level `defaults.sort`; the effective sort is
1413
1445
  * resolved once as `view.sort ?? defaultsSort ?? DEFAULT_SORT` and applied
1414
1446
  * both as the per-project server hint and the authoritative client-side sort.
1447
+ * `stateOrder` is the profile-level `defaults.state_order`, used by the
1448
+ * client-side `state` sort to rank states by the configured slug order.
1415
1449
  */
1416
1450
  // `signal` is threaded down to each per-project fetch so the dashboard can
1417
1451
  // abort an in-flight refresh (e.g. when a new one starts before the previous
1418
1452
  // resolves), preventing requests from piling up and timing out. The positional
1419
1453
  // list mirrors the call sites; the cap is waived rather than reshaping both.
1420
1454
  // eslint-disable-next-line max-params
1421
- async list(projectIdentifiers, view, queryLimit, defaultsSort, signal) {
1455
+ async list(projectIdentifiers, view, queryLimit, defaultsSort, stateOrder, signal) {
1422
1456
  const { issues, failedProjects } = await this.listResilient(
1423
1457
  projectIdentifiers,
1424
1458
  view,
1425
1459
  queryLimit,
1426
1460
  defaultsSort,
1461
+ stateOrder,
1427
1462
  signal
1428
1463
  );
1429
1464
  if (failedProjects.length > 0) {
@@ -1442,7 +1477,7 @@ var IssuesService = class {
1442
1477
  */
1443
1478
  // Same positional shape as `list`; the cap is waived for the same reason.
1444
1479
  // eslint-disable-next-line max-params
1445
- async listResilient(projectIdentifiers, view, queryLimit, defaultsSort, signal) {
1480
+ async listResilient(projectIdentifiers, view, queryLimit, defaultsSort, stateOrder, signal) {
1446
1481
  const assigneeIds = await this.resolveAssigneeIds(view);
1447
1482
  const sort = resolveSort(view?.sort, defaultsSort);
1448
1483
  const effectiveView = view ? { ...view, sort } : { name: "", sort };
@@ -1461,7 +1496,7 @@ var IssuesService = class {
1461
1496
  const byGroup = refineByStateGroup(fetched, view?.filters?.state_group);
1462
1497
  const byState = refineByStateSearch(byGroup, view?.filters);
1463
1498
  const byAssignee = refineByAssignee(byState, assigneeIds);
1464
- const sorted = sortIssues(byAssignee, sort);
1499
+ const sorted = sortIssues(byAssignee, sort, stateOrder);
1465
1500
  const issues = queryLimit !== void 0 ? sorted.slice(0, queryLimit) : sorted;
1466
1501
  return { issues, failedProjects };
1467
1502
  }
@@ -1598,6 +1633,104 @@ var LabelsService = class {
1598
1633
  }
1599
1634
  };
1600
1635
 
1636
+ // src/plane/activities.ts
1637
+ function toActivity(raw) {
1638
+ return {
1639
+ id: raw.id,
1640
+ verb: raw.verb,
1641
+ field: raw.field ?? void 0,
1642
+ oldValue: raw.old_value ?? void 0,
1643
+ newValue: raw.new_value ?? void 0,
1644
+ oldIdentifier: raw.old_identifier ?? void 0,
1645
+ newIdentifier: raw.new_identifier ?? void 0,
1646
+ createdAt: raw.created_at,
1647
+ actor: raw.actor ?? void 0
1648
+ };
1649
+ }
1650
+ function unwrap(res) {
1651
+ if (Array.isArray(res)) return res;
1652
+ if ("result" in res && Array.isArray(res.result)) return res.result;
1653
+ if ("results" in res && Array.isArray(res.results)) return res.results;
1654
+ return [];
1655
+ }
1656
+ var ActivitiesService = class {
1657
+ constructor(api, cache) {
1658
+ this.api = api;
1659
+ this.cache = cache;
1660
+ }
1661
+ api;
1662
+ cache;
1663
+ // list returns the issue's activity log, cache-first. The cached copy lets the
1664
+ // detail view's timing line and the activity tab share a single fetch; the
1665
+ // short TTL means a fresh change shows up the next time the detail opens.
1666
+ async list(project, issueId, signal) {
1667
+ const key = cacheKeys.issueActivities(this.api.workspace, project.id, issueId);
1668
+ const cached2 = await this.cache.get(key);
1669
+ if (cached2) return cached2;
1670
+ const res = await this.api.request(
1671
+ this.api.workspacePath("projects", project.id, "issues", issueId, "activities"),
1672
+ { signal }
1673
+ );
1674
+ const activities = unwrap(res).map(toActivity);
1675
+ await this.cache.set(key, activities, ACTIVITIES_TTL_SECONDS);
1676
+ return activities;
1677
+ }
1678
+ };
1679
+
1680
+ // src/types/relation.ts
1681
+ var RELATION_TYPES = [
1682
+ "blocking",
1683
+ "blocked_by",
1684
+ "relates_to",
1685
+ "duplicate",
1686
+ "start_after",
1687
+ "start_before",
1688
+ "finish_after",
1689
+ "finish_before"
1690
+ ];
1691
+ var RELATION_LABELS = {
1692
+ blocking: "blocking",
1693
+ blocked_by: "blocked by",
1694
+ relates_to: "relates to",
1695
+ duplicate: "duplicate of",
1696
+ start_after: "starts after",
1697
+ start_before: "starts before",
1698
+ finish_after: "finishes after",
1699
+ finish_before: "finishes before"
1700
+ };
1701
+
1702
+ // src/plane/relations.ts
1703
+ function normalize(raw) {
1704
+ const relations = {};
1705
+ for (const type of RELATION_TYPES) {
1706
+ relations[type] = raw[type] ?? [];
1707
+ }
1708
+ return relations;
1709
+ }
1710
+ var RelationsService = class {
1711
+ constructor(api, cache) {
1712
+ this.api = api;
1713
+ this.cache = cache;
1714
+ }
1715
+ api;
1716
+ cache;
1717
+ // list returns the current relations for an issue, cache-first. The short TTL
1718
+ // matches the activity log's: opening the detail and toggling the relations
1719
+ // section reuses one fetch, and a freshly added relation appears on reopen.
1720
+ async list(project, issueId, signal) {
1721
+ const key = cacheKeys.issueRelations(this.api.workspace, project.id, issueId);
1722
+ const cached2 = await this.cache.get(key);
1723
+ if (cached2) return cached2;
1724
+ const raw = await this.api.request(
1725
+ this.api.workspacePath("projects", project.id, "work-items", issueId, "relations"),
1726
+ { signal }
1727
+ );
1728
+ const relations = normalize(raw);
1729
+ await this.cache.set(key, relations, ACTIVITIES_TTL_SECONDS);
1730
+ return relations;
1731
+ }
1732
+ };
1733
+
1601
1734
  // src/keybindings/load.ts
1602
1735
  import { readFile as readFile3 } from "fs/promises";
1603
1736
  import YAML3 from "yaml";
@@ -1719,6 +1852,24 @@ var ACTIONS = [
1719
1852
  },
1720
1853
  { id: "detail.comment", context: "detail", description: "comment on issue", defaultKey: "c" },
1721
1854
  { id: "detail.edit", context: "detail", description: "edit issue fields", defaultKey: "e" },
1855
+ {
1856
+ id: "detail.activity",
1857
+ context: "detail",
1858
+ description: "toggle the state-change activity log",
1859
+ defaultKey: "a"
1860
+ },
1861
+ {
1862
+ id: "detail.relations",
1863
+ context: "detail",
1864
+ description: "toggle the relations section",
1865
+ defaultKey: "l"
1866
+ },
1867
+ {
1868
+ id: "detail.relation-open",
1869
+ context: "detail",
1870
+ description: "open the focused relation",
1871
+ defaultKey: "enter"
1872
+ },
1722
1873
  {
1723
1874
  id: "detail.close",
1724
1875
  context: "detail",
@@ -2013,6 +2164,8 @@ async function buildContext(flags) {
2013
2164
  const users = new UsersService(api, cache);
2014
2165
  const states = new StatesService(api, cache);
2015
2166
  const labels = new LabelsService(api, cache);
2167
+ const activities = new ActivitiesService(api, cache);
2168
+ const relations = new RelationsService(api, cache);
2016
2169
  const issues = new IssuesService(projects, workItems, users);
2017
2170
  const { bindings: keybindings, sourcePath: keybindingsSourcePath } = await loadKeybindings();
2018
2171
  const runtime = {
@@ -2032,6 +2185,8 @@ async function buildContext(flags) {
2032
2185
  users,
2033
2186
  states,
2034
2187
  labels,
2188
+ activities,
2189
+ relations,
2035
2190
  keybindings,
2036
2191
  keybindingsSourcePath,
2037
2192
  theme: resolveTheme(profile.theme),
@@ -2413,7 +2568,8 @@ function registerIssue(program2) {
2413
2568
  projects,
2414
2569
  view,
2415
2570
  limit ?? view?.query_limit,
2416
- ctx.runtime.profile.defaults?.sort
2571
+ ctx.runtime.profile.defaults?.sort,
2572
+ ctx.runtime.profile.defaults?.state_order
2417
2573
  );
2418
2574
  process.stdout.write(renderIssues(issues, format, ctx.theme));
2419
2575
  process.stdout.write("\n");
@@ -2633,7 +2789,7 @@ import React9 from "react";
2633
2789
  import { render } from "ink";
2634
2790
 
2635
2791
  // src/tui/dashboard.tsx
2636
- import React7, { useCallback as useCallback6, useEffect as useEffect5, useMemo as useMemo6, useState as useState11 } from "react";
2792
+ import React7, { useCallback as useCallback7, useEffect as useEffect7, useMemo as useMemo8, useState as useState14 } from "react";
2637
2793
  import { Box as Box12, Text as Text12, useApp, useInput as useInput2 } from "ink";
2638
2794
 
2639
2795
  // src/tui/theme/context.tsx
@@ -2930,6 +3086,16 @@ var PRIORITY_LETTER = {
2930
3086
  low: "L",
2931
3087
  none: "\xB7"
2932
3088
  };
3089
+ var SORT_FIELD_COLUMN = {
3090
+ priority: "priority",
3091
+ state: "state",
3092
+ assign: "assign"
3093
+ };
3094
+ function sortIndicator(column, sort) {
3095
+ const primary = sort?.[0];
3096
+ if (!primary || SORT_FIELD_COLUMN[primary.field] !== column) return "";
3097
+ return primary.direction === "asc" ? " \u2191" : " \u2193";
3098
+ }
2933
3099
  function computeViewport(total, selected, rows, previousStart = 0) {
2934
3100
  if (rows <= 0 || total === 0) return { start: 0, end: 0 };
2935
3101
  if (rows >= total) return { start: 0, end: total };
@@ -2964,10 +3130,16 @@ function IssueList(props) {
2964
3130
  return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", paddingX: 1, flexGrow: 1, children: [
2965
3131
  /* @__PURE__ */ jsxs4(Box4, { columnGap: 1, children: [
2966
3132
  /* @__PURE__ */ jsx5(Box4, { ...cell("key", cols.keyWidth), children: /* @__PURE__ */ jsx5(Text4, { bold: true, children: alignText("KEY", cols.keyWidth, cols.align.key) }) }),
2967
- /* @__PURE__ */ jsx5(Box4, { ...cell("priority", cols.priorityWidth), children: /* @__PURE__ */ jsx5(Text4, { bold: true, children: cols.compactPriority ? "PR" : "PRIORITY" }) }),
2968
- cols.showState ? /* @__PURE__ */ jsx5(Box4, { ...cell("state", cols.stateWidth), children: /* @__PURE__ */ jsx5(Text4, { bold: true, children: "STATE" }) }) : null,
3133
+ /* @__PURE__ */ jsx5(Box4, { ...cell("priority", cols.priorityWidth), children: /* @__PURE__ */ jsx5(Text4, { bold: true, children: cols.compactPriority ? "PR" : `PRIORITY${sortIndicator("priority", props.sort)}` }) }),
3134
+ cols.showState ? /* @__PURE__ */ jsx5(Box4, { ...cell("state", cols.stateWidth), children: /* @__PURE__ */ jsxs4(Text4, { bold: true, children: [
3135
+ "STATE",
3136
+ sortIndicator("state", props.sort)
3137
+ ] }) }) : null,
2969
3138
  /* @__PURE__ */ jsx5(Box4, { ...cell("title", cols.title), children: /* @__PURE__ */ jsx5(Text4, { bold: true, children: "TITLE" }) }),
2970
- cols.showAssign ? /* @__PURE__ */ jsx5(Box4, { ...cell("assign", cols.assignWidth), children: /* @__PURE__ */ jsx5(Text4, { bold: true, children: "ASSIGN" }) }) : null
3139
+ cols.showAssign ? /* @__PURE__ */ jsx5(Box4, { ...cell("assign", cols.assignWidth), children: /* @__PURE__ */ jsxs4(Text4, { bold: true, children: [
3140
+ "ASSIGN",
3141
+ sortIndicator("assign", props.sort)
3142
+ ] }) }) : null
2971
3143
  ] }),
2972
3144
  hiddenAbove > 0 ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
2973
3145
  "\u2191 ",
@@ -3011,7 +3183,7 @@ function IssueList(props) {
3011
3183
  }
3012
3184
 
3013
3185
  // src/tui/issue-detail.tsx
3014
- import { useMemo } from "react";
3186
+ import React5, { useMemo } from "react";
3015
3187
  import { Box as Box5, Text as Text5 } from "ink";
3016
3188
 
3017
3189
  // src/utils/markdown-to-ansi.ts
@@ -3168,18 +3340,67 @@ function splitAnsiIntoLines(text, width) {
3168
3340
  return rows;
3169
3341
  }
3170
3342
 
3343
+ // src/utils/format-duration.ts
3344
+ var MINUTE_MS = 6e4;
3345
+ var HOUR_MS = 60 * MINUTE_MS;
3346
+ var DAY_MS = 24 * HOUR_MS;
3347
+ function humanizeDuration(ms) {
3348
+ if (ms < MINUTE_MS) return "just now";
3349
+ const days = Math.floor(ms / DAY_MS);
3350
+ const hours = Math.floor(ms % DAY_MS / HOUR_MS);
3351
+ const minutes = Math.floor(ms % HOUR_MS / MINUTE_MS);
3352
+ const parts = [];
3353
+ if (days > 0) parts.push(`${days}d`);
3354
+ if (hours > 0) parts.push(`${hours}h`);
3355
+ if (minutes > 0 && days === 0) parts.push(`${minutes}m`);
3356
+ return parts.slice(0, 2).join(" ");
3357
+ }
3358
+
3359
+ // src/tui/format-relation.ts
3360
+ function formatRelationRow(relation, now) {
3361
+ const head = relation.targetKey ?? `${relation.targetId.slice(0, 8)}\u2026`;
3362
+ const parts = [head];
3363
+ if (relation.target) {
3364
+ parts.push(relation.target.state.name);
3365
+ if (relation.target.name) parts.push(relation.target.name);
3366
+ }
3367
+ if (relation.relatedAt) {
3368
+ const when = Date.parse(relation.relatedAt);
3369
+ if (!Number.isNaN(when)) {
3370
+ const elapsed = humanizeDuration(now - when);
3371
+ parts.push(elapsed === "just now" ? "just now" : `${elapsed} ago`);
3372
+ }
3373
+ }
3374
+ return parts.join(" \xB7 ");
3375
+ }
3376
+
3171
3377
  // src/tui/issue-detail.tsx
3172
3378
  import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
3379
+ function formatStateChange(activity, now) {
3380
+ const when = Date.parse(activity.createdAt);
3381
+ const from = activity.oldValue ? `${activity.oldValue} \u2192 ` : "\u2192 ";
3382
+ const head = `${from}${activity.newValue ?? "?"}`;
3383
+ if (Number.isNaN(when)) return head;
3384
+ const elapsed = humanizeDuration(now - when);
3385
+ return elapsed === "just now" ? `${head} \xB7 just now` : `${head} \xB7 ${elapsed} ago`;
3386
+ }
3173
3387
  var MODAL_WIDTH = 100;
3174
3388
  var PANEL_WIDTH = 50;
3175
3389
  var HORIZONTAL_CHROME = 4;
3176
3390
  var DETAIL_CHROME_ROWS = 16;
3177
- function IssueMeta({ issue }) {
3391
+ function IssueMeta({
3392
+ issue,
3393
+ timeInState
3394
+ }) {
3178
3395
  const theme = useTheme();
3179
3396
  return /* @__PURE__ */ jsxs5(Box5, { marginTop: 1, flexDirection: "column", flexShrink: 0, children: [
3180
3397
  /* @__PURE__ */ jsxs5(Text5, { children: [
3181
3398
  "state: ",
3182
- /* @__PURE__ */ jsx6(Text5, { color: theme.accent, children: issue.state.name })
3399
+ /* @__PURE__ */ jsx6(Text5, { color: theme.accent, children: issue.state.name }),
3400
+ timeInState ? /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
3401
+ " \xB7 for ",
3402
+ timeInState
3403
+ ] }) : null
3183
3404
  ] }),
3184
3405
  /* @__PURE__ */ jsxs5(Text5, { children: [
3185
3406
  "priority: ",
@@ -3199,12 +3420,12 @@ function IssueMeta({ issue }) {
3199
3420
  ] })
3200
3421
  ] });
3201
3422
  }
3202
- function DescriptionBody(props) {
3423
+ function ScrollableBody(props) {
3203
3424
  let content;
3204
- if (props.loading && !props.hasDescription) {
3205
- content = /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: "loading description\u2026" });
3425
+ if (props.loading && props.visible.length === 0) {
3426
+ content = /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: props.loadingLabel });
3206
3427
  } else if (props.visible.length === 0 && props.hiddenAbove === 0 && props.hiddenBelow === 0) {
3207
- content = /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: "(no description)" });
3428
+ content = /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: props.emptyLabel });
3208
3429
  } else {
3209
3430
  content = /* @__PURE__ */ jsxs5(Fragment2, { children: [
3210
3431
  props.hiddenAbove > 0 ? /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
@@ -3222,60 +3443,173 @@ function DescriptionBody(props) {
3222
3443
  }
3223
3444
  return /* @__PURE__ */ jsx6(Box5, { marginTop: 1, flexDirection: "column", overflow: "hidden", children: content });
3224
3445
  }
3225
- function closeHintFor(scrollTop, viewportRows, total) {
3226
- if (total <= viewportRows) return "esc to close";
3446
+ function RelationRow({
3447
+ relation,
3448
+ focused,
3449
+ now
3450
+ }) {
3451
+ const theme = useTheme();
3452
+ return /* @__PURE__ */ jsxs5(Text5, { wrap: "truncate", color: focused ? theme.accent : void 0, bold: focused, children: [
3453
+ focused ? "\u203A " : " ",
3454
+ formatRelationRow(relation, now)
3455
+ ] });
3456
+ }
3457
+ function RelationsBody(props) {
3458
+ if (props.relations.length === 0) {
3459
+ return /* @__PURE__ */ jsx6(Box5, { marginTop: 1, flexDirection: "column", overflow: "hidden", children: /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: props.loading ? "loading relations\u2026" : "(no relations)" }) });
3460
+ }
3461
+ const now = Date.now();
3462
+ const start = Math.max(
3463
+ 0,
3464
+ Math.min(props.selected - 1, props.relations.length - props.viewportRows)
3465
+ );
3466
+ const window = props.relations.slice(Math.max(0, start), Math.max(0, start) + props.viewportRows);
3467
+ let lastType;
3468
+ return /* @__PURE__ */ jsx6(Box5, { marginTop: 1, flexDirection: "column", overflow: "hidden", children: window.map((relation, idx) => {
3469
+ const flatIdx = Math.max(0, start) + idx;
3470
+ const heading = relation.type !== lastType ? RELATION_LABELS[relation.type] : void 0;
3471
+ lastType = relation.type;
3472
+ return /* @__PURE__ */ jsxs5(React5.Fragment, { children: [
3473
+ heading ? /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
3474
+ heading,
3475
+ ":"
3476
+ ] }) : null,
3477
+ /* @__PURE__ */ jsx6(RelationRow, { relation, focused: flatIdx === props.selected, now })
3478
+ ] }, `${relation.type}-${relation.targetId}`);
3479
+ }) });
3480
+ }
3481
+ var HINT_BY_MODE = {
3482
+ detail: "esc to close \xB7 a: activity \xB7 l: relations",
3483
+ activity: "esc to close \xB7 a: description",
3484
+ relations: "esc back \xB7 enter: open \xB7 l: description"
3485
+ };
3486
+ function closeHintFor(scrollTop, viewportRows, total, mode) {
3487
+ const label = HINT_BY_MODE[mode];
3488
+ if (mode === "relations" || total <= viewportRows) return label;
3227
3489
  const end = Math.min(scrollTop + viewportRows, total);
3228
- return `esc to close \xB7 ${scrollTop + 1}-${end}/${total}`;
3490
+ return `${label} \xB7 ${scrollTop + 1}-${end}/${total}`;
3491
+ }
3492
+ function DetailHeader(props) {
3493
+ return /* @__PURE__ */ jsxs5(Box5, { justifyContent: "space-between", flexShrink: 0, children: [
3494
+ /* @__PURE__ */ jsxs5(Text5, { bold: true, children: [
3495
+ props.issueKey,
3496
+ props.mode !== "detail" ? /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
3497
+ " \xB7 ",
3498
+ props.mode
3499
+ ] }) : null
3500
+ ] }),
3501
+ props.variant === "modal" ? /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: closeHintFor(props.scrollTop, props.viewportRows, props.total, props.mode) }) : null
3502
+ ] });
3503
+ }
3504
+ var BODY_LABELS = {
3505
+ detail: { loadingLabel: "loading description\u2026", emptyLabel: "(no description)" },
3506
+ activity: { loadingLabel: "loading activity\u2026", emptyLabel: "(no state changes)" }
3507
+ };
3508
+ function resolveBody(mode, lines, props) {
3509
+ const viewportRows = props.viewportRows ?? lines.length;
3510
+ const scrollTop = Math.max(
3511
+ 0,
3512
+ Math.min(props.scrollTop ?? 0, Math.max(0, lines.length - viewportRows))
3513
+ );
3514
+ return {
3515
+ lines,
3516
+ visible: lines.slice(scrollTop, scrollTop + viewportRows),
3517
+ scrollTop,
3518
+ hiddenBelow: Math.max(0, lines.length - scrollTop - viewportRows),
3519
+ loading: (mode === "activity" ? props.activityLoading : props.loading) ?? false,
3520
+ ...BODY_LABELS[mode]
3521
+ };
3522
+ }
3523
+ function frameStyle(variant, accent) {
3524
+ return variant === "modal" ? { borderStyle: "double", borderColor: accent, paddingY: 1 } : { borderStyle: "round", borderColor: void 0, paddingY: 0 };
3525
+ }
3526
+ function DetailBody(props) {
3527
+ if (props.mode === "relations") {
3528
+ return /* @__PURE__ */ jsx6(
3529
+ RelationsBody,
3530
+ {
3531
+ relations: props.relations,
3532
+ selected: props.relationsSelected,
3533
+ loading: props.relationsLoading,
3534
+ viewportRows: props.relationsViewport
3535
+ }
3536
+ );
3537
+ }
3538
+ const b = props.textBody;
3539
+ return /* @__PURE__ */ jsx6(
3540
+ ScrollableBody,
3541
+ {
3542
+ loading: b.loading,
3543
+ loadingLabel: b.loadingLabel,
3544
+ emptyLabel: b.emptyLabel,
3545
+ visible: b.visible,
3546
+ hiddenAbove: b.scrollTop,
3547
+ hiddenBelow: b.hiddenBelow,
3548
+ scrollTop: b.scrollTop
3549
+ }
3550
+ );
3229
3551
  }
3230
3552
  function IssueDetail(props) {
3231
3553
  const theme = useTheme();
3232
3554
  const variant = props.variant ?? "panel";
3555
+ const mode = props.mode ?? "detail";
3233
3556
  const width = variant === "modal" ? MODAL_WIDTH : PANEL_WIDTH;
3234
3557
  const contentWidth = width - HORIZONTAL_CHROME;
3235
3558
  const description = props.issue?.description;
3236
- const rendered = useMemo(() => description ? markdownToAnsi(description) : "", [description]);
3237
- const lines = useMemo(
3238
- () => rendered ? splitAnsiIntoLines(rendered, contentWidth) : [],
3239
- [rendered, contentWidth]
3559
+ const stateChanges = props.stateChanges;
3560
+ const descriptionLines = useMemo(
3561
+ () => description ? splitAnsiIntoLines(markdownToAnsi(description), contentWidth) : [],
3562
+ [description, contentWidth]
3240
3563
  );
3564
+ const activityLines = useMemo(() => {
3565
+ const now = Date.now();
3566
+ return (stateChanges ?? []).map((a) => formatStateChange(a, now)).reverse();
3567
+ }, [stateChanges]);
3241
3568
  if (!props.issue) {
3242
3569
  return /* @__PURE__ */ jsx6(Box5, { borderStyle: "round", paddingX: 1, width, children: /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: "select an issue" }) });
3243
3570
  }
3244
3571
  const i = props.issue;
3245
- const viewportRows = props.viewportRows ?? lines.length;
3246
- const scrollTop = Math.max(
3247
- 0,
3248
- Math.min(props.scrollTop ?? 0, Math.max(0, lines.length - viewportRows))
3572
+ const isActivity = mode === "activity";
3573
+ const textBody = resolveBody(
3574
+ isActivity ? "activity" : "detail",
3575
+ isActivity ? activityLines : descriptionLines,
3576
+ props
3249
3577
  );
3250
- const visible = lines.slice(scrollTop, scrollTop + viewportRows);
3578
+ const viewportRows = props.viewportRows ?? textBody.lines.length;
3579
+ const relationsViewport = Math.max(1, viewportRows);
3251
3580
  return /* @__PURE__ */ jsxs5(
3252
3581
  Box5,
3253
3582
  {
3254
3583
  flexDirection: "column",
3255
- borderStyle: variant === "modal" ? "double" : "round",
3256
- borderColor: variant === "modal" ? theme.accent : void 0,
3584
+ ...frameStyle(variant, theme.accent),
3257
3585
  paddingX: 1,
3258
- paddingY: variant === "modal" ? 1 : 0,
3259
3586
  width,
3260
3587
  height: props.height,
3261
3588
  flexShrink: 0,
3262
3589
  overflow: "hidden",
3263
3590
  children: [
3264
- /* @__PURE__ */ jsxs5(Box5, { justifyContent: "space-between", flexShrink: 0, children: [
3265
- /* @__PURE__ */ jsx6(Text5, { bold: true, children: i.key }),
3266
- variant === "modal" ? /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: closeHintFor(scrollTop, viewportRows, lines.length) }) : null
3267
- ] }),
3591
+ /* @__PURE__ */ jsx6(
3592
+ DetailHeader,
3593
+ {
3594
+ issueKey: i.key,
3595
+ mode,
3596
+ variant,
3597
+ scrollTop: textBody.scrollTop,
3598
+ viewportRows,
3599
+ total: textBody.lines.length
3600
+ }
3601
+ ),
3268
3602
  /* @__PURE__ */ jsx6(Text5, { wrap: "truncate", children: i.name }),
3269
- /* @__PURE__ */ jsx6(IssueMeta, { issue: i }),
3603
+ /* @__PURE__ */ jsx6(IssueMeta, { issue: i, timeInState: props.timeInState }),
3270
3604
  /* @__PURE__ */ jsx6(
3271
- DescriptionBody,
3605
+ DetailBody,
3272
3606
  {
3273
- loading: props.loading ?? false,
3274
- hasDescription: Boolean(i.description),
3275
- visible,
3276
- hiddenAbove: scrollTop,
3277
- hiddenBelow: Math.max(0, lines.length - scrollTop - viewportRows),
3278
- scrollTop
3607
+ mode,
3608
+ textBody,
3609
+ relations: props.relations ?? [],
3610
+ relationsSelected: props.relationsSelected ?? 0,
3611
+ relationsLoading: props.relationsLoading ?? false,
3612
+ relationsViewport
3279
3613
  }
3280
3614
  )
3281
3615
  ]
@@ -4372,7 +4706,10 @@ var EMPTY_VIEW_DATA = {
4372
4706
  failedProjects: []
4373
4707
  };
4374
4708
  function useViewsData(opts) {
4375
- const { views, issuesService, defaultProjects, defaultsSort, logger } = opts;
4709
+ const { views, issuesService, defaults, logger } = opts;
4710
+ const defaultProjects = defaults?.projects;
4711
+ const defaultsSort = defaults?.sort;
4712
+ const stateOrder = defaults?.state_order;
4376
4713
  const [byView, setByView] = useState6(() => views.map(() => EMPTY_VIEW_DATA));
4377
4714
  const viewsRef = useRef4(views);
4378
4715
  viewsRef.current = views;
@@ -4414,6 +4751,7 @@ function useViewsData(opts) {
4414
4751
  view,
4415
4752
  view.query_limit ?? 100,
4416
4753
  defaultsSort,
4754
+ stateOrder,
4417
4755
  controller.signal
4418
4756
  );
4419
4757
  patch(viewIdx, {
@@ -4446,7 +4784,7 @@ function useViewsData(opts) {
4446
4784
  }
4447
4785
  }
4448
4786
  },
4449
- [issuesService, defaultProjects, defaultsSort, logger, patch]
4787
+ [issuesService, defaultProjects, defaultsSort, stateOrder, logger, patch]
4450
4788
  );
4451
4789
  const refreshAll = useCallback4(() => {
4452
4790
  const indices = viewsRef.current.map((_, idx) => idx);
@@ -4534,23 +4872,17 @@ function useIssueFilter(opts) {
4534
4872
  import { useCallback as useCallback5, useState as useState9 } from "react";
4535
4873
 
4536
4874
  // src/plane/state-order.ts
4537
- var GROUP_ORDER = [
4538
- "backlog",
4539
- "unstarted",
4540
- "started",
4541
- "completed",
4542
- "cancelled"
4543
- ];
4544
- function orderStates(states) {
4875
+ function orderStates(states, stateOrder) {
4876
+ const rank = buildStateRank(stateOrder);
4545
4877
  return states.map((state, index) => ({ state, index })).sort((a, b) => {
4546
- const ga = GROUP_ORDER.indexOf(a.state.group);
4547
- const gb = GROUP_ORDER.indexOf(b.state.group);
4548
- if (ga !== gb) return ga - gb;
4878
+ const ra = rank(a.state);
4879
+ const rb = rank(b.state);
4880
+ if (ra !== rb) return ra - rb;
4549
4881
  return a.index - b.index;
4550
4882
  }).map((entry) => entry.state);
4551
4883
  }
4552
- function neighbourState(states, currentId, direction) {
4553
- const ordered = orderStates(states);
4884
+ function neighbourState(states, currentId, direction, stateOrder) {
4885
+ const ordered = orderStates(states, stateOrder);
4554
4886
  const idx = ordered.findIndex((s) => s.id === currentId);
4555
4887
  if (idx < 0) return void 0;
4556
4888
  const target = idx + direction;
@@ -4581,7 +4913,8 @@ function useQuickTransition(opts) {
4581
4913
  if (!issue) return;
4582
4914
  try {
4583
4915
  const states = await ctx.states.list(projectOf(issue));
4584
- const next = neighbourState(states, issue.state.id, direction);
4916
+ const stateOrder = ctx.runtime.profile.defaults?.state_order;
4917
+ const next = neighbourState(states, issue.state.id, direction, stateOrder);
4585
4918
  if (!next) {
4586
4919
  setMessage(`${issue.key}: already at the ${direction === 1 ? "last" : "first"} state`);
4587
4920
  return;
@@ -4668,6 +5001,183 @@ function useDetailPanel(opts) {
4668
5001
  };
4669
5002
  }
4670
5003
 
5004
+ // src/tui/use-activity-log.ts
5005
+ import { useEffect as useEffect5, useMemo as useMemo6, useState as useState11 } from "react";
5006
+
5007
+ // src/types/activity.ts
5008
+ var STATE_FIELD = "state";
5009
+ function isStateChange(activity) {
5010
+ return activity.field === STATE_FIELD;
5011
+ }
5012
+
5013
+ // src/plane/state-duration.ts
5014
+ function currentStateEntry(activities, issueCreatedAt) {
5015
+ const transitions = activities.filter(isStateChange).map((a) => a.createdAt).sort();
5016
+ return transitions.at(-1) ?? issueCreatedAt;
5017
+ }
5018
+ function timeInCurrentState(activities, issueCreatedAt, now) {
5019
+ const entry = Date.parse(currentStateEntry(activities, issueCreatedAt));
5020
+ if (Number.isNaN(entry)) return 0;
5021
+ return Math.max(0, now - entry);
5022
+ }
5023
+
5024
+ // src/tui/use-activity-log.ts
5025
+ function useActivityLog(opts) {
5026
+ const { open: open2, target, createdAt, ctx, logger } = opts;
5027
+ const [activities, setActivities] = useState11();
5028
+ const [loading, setLoading] = useState11(false);
5029
+ useEffect5(() => {
5030
+ if (!open2 || !target) {
5031
+ setActivities(void 0);
5032
+ return;
5033
+ }
5034
+ const controller = new AbortController();
5035
+ setLoading(true);
5036
+ const project = {
5037
+ id: target.project_id,
5038
+ identifier: target.project_identifier,
5039
+ name: "",
5040
+ workspace_id: ""
5041
+ };
5042
+ ctx.activities.list(project, target.id, controller.signal).then((log) => {
5043
+ if (!controller.signal.aborted) setActivities(log);
5044
+ }).catch((err) => {
5045
+ if (controller.signal.aborted) return;
5046
+ logger.warn("activity log fetch failed", { issue: target.key, err });
5047
+ setActivities(void 0);
5048
+ }).finally(() => {
5049
+ if (!controller.signal.aborted) setLoading(false);
5050
+ });
5051
+ return () => {
5052
+ controller.abort();
5053
+ };
5054
+ }, [open2, target, ctx, logger]);
5055
+ const stateChanges = useMemo6(
5056
+ () => activities ? activities.filter(isStateChange) : [],
5057
+ [activities]
5058
+ );
5059
+ const timeInState = useMemo6(() => {
5060
+ if (!activities || !createdAt) return void 0;
5061
+ return humanizeDuration(timeInCurrentState(activities, createdAt, Date.now()));
5062
+ }, [activities, createdAt]);
5063
+ return { activities, stateChanges, loading, timeInState };
5064
+ }
5065
+
5066
+ // src/tui/use-relations.ts
5067
+ import { useEffect as useEffect6, useMemo as useMemo7, useState as useState12 } from "react";
5068
+
5069
+ // src/plane/relation-view.ts
5070
+ function indexAddEvents(activities) {
5071
+ const byTarget = /* @__PURE__ */ new Map();
5072
+ for (const a of activities) {
5073
+ if (!a.field || !RELATION_TYPES.includes(a.field)) continue;
5074
+ const target = a.oldIdentifier;
5075
+ if (!target) continue;
5076
+ const existing = byTarget.get(target);
5077
+ if (!existing || a.createdAt > existing.relatedAt) {
5078
+ byTarget.set(target, { relatedAt: a.createdAt, key: a.newValue });
5079
+ }
5080
+ }
5081
+ return byTarget;
5082
+ }
5083
+ function buildRelations(relations, activities) {
5084
+ const addEvents = indexAddEvents(activities);
5085
+ const result = [];
5086
+ for (const type of RELATION_TYPES) {
5087
+ for (const targetId of relations[type] ?? []) {
5088
+ const add = addEvents.get(targetId);
5089
+ result.push({ type, targetId, targetKey: add?.key, relatedAt: add?.relatedAt });
5090
+ }
5091
+ }
5092
+ return result;
5093
+ }
5094
+
5095
+ // src/tui/use-relations.ts
5096
+ function useRelations(opts) {
5097
+ const { open: open2, target, activities, ctx, logger } = opts;
5098
+ const [relations, setRelations] = useState12();
5099
+ const [loading, setLoading] = useState12(false);
5100
+ const [targets, setTargets] = useState12({});
5101
+ useEffect6(() => {
5102
+ if (!open2 || !target) {
5103
+ setRelations(void 0);
5104
+ setTargets({});
5105
+ return;
5106
+ }
5107
+ const controller = new AbortController();
5108
+ setLoading(true);
5109
+ const project = {
5110
+ id: target.project_id,
5111
+ identifier: target.project_identifier,
5112
+ name: "",
5113
+ workspace_id: ""
5114
+ };
5115
+ ctx.relations.list(project, target.id, controller.signal).then((current) => {
5116
+ if (!controller.signal.aborted) setRelations(buildRelations(current, activities ?? []));
5117
+ }).catch((err) => {
5118
+ if (controller.signal.aborted) return;
5119
+ logger.warn("relations fetch failed", { issue: target.key, err });
5120
+ setRelations(void 0);
5121
+ }).finally(() => {
5122
+ if (!controller.signal.aborted) setLoading(false);
5123
+ });
5124
+ return () => {
5125
+ controller.abort();
5126
+ };
5127
+ }, [open2, target, activities, ctx, logger]);
5128
+ useEffect6(() => {
5129
+ if (!relations) return;
5130
+ let cancelled = false;
5131
+ const pending = relations.map((r) => r.targetKey).filter((key) => Boolean(key) && !(key in targets));
5132
+ for (const key of pending) {
5133
+ ctx.issues.view(key).then((issue) => {
5134
+ if (!cancelled) setTargets((prev) => ({ ...prev, [key]: issue }));
5135
+ }).catch((err) => {
5136
+ logger.warn("relation target resolve failed", { key, err });
5137
+ });
5138
+ }
5139
+ return () => {
5140
+ cancelled = true;
5141
+ };
5142
+ }, [relations, targets, ctx, logger]);
5143
+ const enriched = useMemo7(
5144
+ () => (relations ?? []).map(
5145
+ (r) => r.targetKey && targets[r.targetKey] ? { ...r, target: targets[r.targetKey] } : r
5146
+ ),
5147
+ [relations, targets]
5148
+ );
5149
+ return { relations: enriched, loading };
5150
+ }
5151
+
5152
+ // src/tui/use-detail-stack.ts
5153
+ import { useCallback as useCallback6, useState as useState13 } from "react";
5154
+ function targetFromIssue(issue) {
5155
+ return {
5156
+ id: issue.id,
5157
+ key: issue.key,
5158
+ project_id: issue.project_id,
5159
+ project_identifier: issue.project_identifier
5160
+ };
5161
+ }
5162
+ function useDetailStack() {
5163
+ const [stack, setStack] = useState13([]);
5164
+ const open2 = useCallback6((target) => setStack([target]), []);
5165
+ const push = useCallback6(
5166
+ (target) => setStack((s) => s.at(-1)?.id === target.id ? s : [...s, target]),
5167
+ []
5168
+ );
5169
+ const pop = useCallback6(() => setStack((s) => s.slice(0, -1)), []);
5170
+ const close = useCallback6(() => setStack([]), []);
5171
+ return {
5172
+ current: stack.at(-1),
5173
+ canGoBack: stack.length > 1,
5174
+ open: open2,
5175
+ push,
5176
+ pop,
5177
+ close
5178
+ };
5179
+ }
5180
+
4671
5181
  // src/tui/dashboard.tsx
4672
5182
  import { jsx as jsx13, jsxs as jsxs12 } from "react/jsx-runtime";
4673
5183
  var NARROW_BREAKPOINT = 100;
@@ -4817,6 +5327,13 @@ function renderActiveOverlay(opts) {
4817
5327
  issue: current,
4818
5328
  loading: detail.loading,
4819
5329
  variant: "modal",
5330
+ mode: opts.detailMode,
5331
+ timeInState: opts.activity.timeInState,
5332
+ stateChanges: opts.activity.stateChanges,
5333
+ activityLoading: opts.activity.loading,
5334
+ relations: opts.relations.relations,
5335
+ relationsSelected: opts.relationsSelected,
5336
+ relationsLoading: opts.relations.loading,
4820
5337
  scrollTop: detail.scroll,
4821
5338
  viewportRows: opts.detailViewportRows,
4822
5339
  height: opts.detailModalHeight
@@ -4847,32 +5364,40 @@ function overlayStatusPosition(opts) {
4847
5364
  if (opts.isDetail && !opts.current) return void 0;
4848
5365
  return opts.listPosition;
4849
5366
  }
5367
+ function resolveViewPresentation(view, defaults) {
5368
+ return {
5369
+ layout: resolveLayout(view?.layout, defaults?.layout),
5370
+ sort: resolveSort(view?.sort, defaults?.sort)
5371
+ };
5372
+ }
4850
5373
  function Dashboard({ ctx, logger }) {
4851
5374
  const { exit } = useApp();
4852
5375
  const { rows: terminalRows, columns: terminalCols } = useTerminalSize();
4853
5376
  const narrow = isNarrowLayout(terminalCols);
4854
- const views = useMemo6(() => ctx.runtime.profile.views ?? [], [ctx]);
4855
- const defaultProjects = useMemo6(() => ctx.runtime.profile.defaults?.projects ?? [], [ctx]);
4856
- const viewEntries = useMemo6(
5377
+ const views = useMemo8(() => ctx.runtime.profile.views ?? [], [ctx]);
5378
+ const defaultProjects = useMemo8(() => ctx.runtime.profile.defaults?.projects ?? [], [ctx]);
5379
+ const viewEntries = useMemo8(
4857
5380
  () => buildViewEntries(views, defaultProjects),
4858
5381
  [views, defaultProjects]
4859
5382
  );
4860
- const [viewIdx, setViewIdx] = useState11(0);
4861
- const [selected, setSelected] = useState11(0);
4862
- const [statusMessage, setStatusMessage] = useState11();
4863
- const [panel, setPanel] = useState11("list");
4864
- const [helpOpen, setHelpOpen] = useState11(false);
5383
+ const [viewIdx, setViewIdx] = useState14(0);
5384
+ const [selected, setSelected] = useState14(0);
5385
+ const [statusMessage, setStatusMessage] = useState14();
5386
+ const stack = useDetailStack();
5387
+ const detailOpen = stack.current !== void 0;
5388
+ const [detailMode, setDetailMode] = useState14("detail");
5389
+ const [relationsSelected, setRelationsSelected] = useState14(0);
5390
+ const [helpOpen, setHelpOpen] = useState14(false);
4865
5391
  const activeView = views[viewIdx];
4866
- const activeLayout = useMemo6(
4867
- () => resolveLayout(activeView?.layout, ctx.runtime.profile.defaults?.layout),
5392
+ const { layout: activeLayout, sort: activeSort } = useMemo8(
5393
+ () => resolveViewPresentation(activeView, ctx.runtime.profile.defaults),
4868
5394
  [activeView, ctx]
4869
5395
  );
4870
5396
  const selectedKeyRef = React7.useRef(void 0);
4871
5397
  const viewsData = useViewsData({
4872
5398
  views,
4873
5399
  issuesService: ctx.issues,
4874
- defaultProjects: ctx.runtime.profile.defaults?.projects,
4875
- defaultsSort: ctx.runtime.profile.defaults?.sort,
5400
+ defaults: ctx.runtime.profile.defaults,
4876
5401
  logger
4877
5402
  });
4878
5403
  const active = viewsData.byView[viewIdx] ?? {
@@ -4886,7 +5411,7 @@ function Dashboard({ ctx, logger }) {
4886
5411
  const loading = active.loading;
4887
5412
  const partialMessage = active.failedProjects.length > 0 ? `partial: ${active.failedProjects.length} project(s) unavailable (${active.failedProjects.join(", ")})` : void 0;
4888
5413
  const error = statusMessage ?? active.error ?? partialMessage;
4889
- const navbarEntries = useMemo6(
5414
+ const navbarEntries = useMemo8(
4890
5415
  () => viewEntries.map((entry, idx) => {
4891
5416
  const data = viewsData.byView[idx];
4892
5417
  return {
@@ -4898,7 +5423,7 @@ function Dashboard({ ctx, logger }) {
4898
5423
  [viewEntries, viewsData.byView]
4899
5424
  );
4900
5425
  const loadView = viewsData.load;
4901
- const load = useCallback6(
5426
+ const load = useCallback7(
4902
5427
  async (preserveSelection = false) => {
4903
5428
  const previousKey = preserveSelection ? selectedKeyRef.current : void 0;
4904
5429
  const keys = await loadView(viewIdx);
@@ -4908,12 +5433,12 @@ function Dashboard({ ctx, logger }) {
4908
5433
  );
4909
5434
  const loadedRef = React7.useRef(viewsData.byView);
4910
5435
  loadedRef.current = viewsData.byView;
4911
- useEffect5(() => {
5436
+ useEffect7(() => {
4912
5437
  if (!activeView) return;
4913
5438
  if (loadedRef.current[viewIdx]?.loaded) setSelected(0);
4914
5439
  else void load();
4915
5440
  }, [viewIdx, activeView, load]);
4916
- useEffect5(() => {
5441
+ useEffect7(() => {
4917
5442
  logger.info("dashboard started", {
4918
5443
  profile: ctx.runtime.profile_name,
4919
5444
  workspace: ctx.runtime.profile.server.workspace_slug,
@@ -4935,7 +5460,7 @@ function Dashboard({ ctx, logger }) {
4935
5460
  logger
4936
5461
  });
4937
5462
  const currentSummary = filtered[selected];
4938
- useEffect5(() => {
5463
+ useEffect7(() => {
4939
5464
  selectedKeyRef.current = currentSummary?.key;
4940
5465
  }, [currentSummary]);
4941
5466
  const comments = useCommentEditor({
@@ -4957,7 +5482,7 @@ function Dashboard({ ctx, logger }) {
4957
5482
  name: "",
4958
5483
  workspace_id: ""
4959
5484
  });
4960
- const reconcile = useCallback6(
5485
+ const reconcile = useCallback7(
4961
5486
  (updated, touchesFilter) => {
4962
5487
  if (touchesFilter) void load(true);
4963
5488
  else viewsData.patchIssue(viewIdx, updated);
@@ -4985,7 +5510,7 @@ function Dashboard({ ctx, logger }) {
4985
5510
  setStatusMessage(message);
4986
5511
  }
4987
5512
  });
4988
- const activeProjects = useMemo6(
5513
+ const activeProjects = useMemo8(
4989
5514
  () => resolveViewProjectsLenient(activeView ?? { name: "" }, defaultProjects).projects,
4990
5515
  [activeView, defaultProjects]
4991
5516
  );
@@ -5031,11 +5556,11 @@ function Dashboard({ ctx, logger }) {
5031
5556
  creator.active,
5032
5557
  transition.active,
5033
5558
  helpOpen,
5034
- panel === "detail",
5559
+ detailOpen,
5035
5560
  filtering
5036
5561
  ].some(Boolean);
5037
5562
  const intervalMs = autoRefreshIntervalMs(ctx.runtime.profile.defaults?.auto_refresh_seconds);
5038
- useEffect5(() => {
5563
+ useEffect7(() => {
5039
5564
  if (intervalMs === void 0 || overlayActive || !activeView) return;
5040
5565
  const timer = setInterval(() => {
5041
5566
  void load(true);
@@ -5043,12 +5568,30 @@ function Dashboard({ ctx, logger }) {
5043
5568
  return () => clearInterval(timer);
5044
5569
  }, [intervalMs, overlayActive, activeView, load]);
5045
5570
  const detail = useDetailPanel({
5046
- open: panel === "detail",
5047
- target: currentSummary,
5571
+ open: detailOpen,
5572
+ target: stack.current,
5048
5573
  ctx,
5049
5574
  logger,
5050
5575
  setMessage: setStatusMessage
5051
5576
  });
5577
+ const activity = useActivityLog({
5578
+ open: detailOpen,
5579
+ target: stack.current,
5580
+ createdAt: detail.detailed?.created_at,
5581
+ ctx,
5582
+ logger
5583
+ });
5584
+ const relations = useRelations({
5585
+ open: detailOpen,
5586
+ target: stack.current,
5587
+ activities: activity.activities,
5588
+ ctx,
5589
+ logger
5590
+ });
5591
+ useEffect7(() => {
5592
+ setDetailMode("detail");
5593
+ setRelationsSelected(0);
5594
+ }, [stack.current]);
5052
5595
  const viewportRows = listViewportRows({
5053
5596
  terminalRows,
5054
5597
  filtering,
@@ -5058,7 +5601,7 @@ function Dashboard({ ctx, logger }) {
5058
5601
  const STATUS_BAR_ROWS = 3;
5059
5602
  const detailModalHeight = Math.max(DETAIL_CHROME_ROWS + 3, terminalRows - STATUS_BAR_ROWS);
5060
5603
  const detailViewportRows = Math.max(3, detailModalHeight - DETAIL_CHROME_ROWS);
5061
- const openSelectedInBrowser = useCallback6(() => {
5604
+ const openSelectedInBrowser = useCallback7(() => {
5062
5605
  const issue = filtered[selected];
5063
5606
  if (!issue) return;
5064
5607
  try {
@@ -5091,7 +5634,9 @@ function Dashboard({ ctx, logger }) {
5091
5634
  "list.page-up": () => setSelected((s) => Math.max(0, s - viewportRows)),
5092
5635
  "list.top": () => setSelected(0),
5093
5636
  "list.bottom": () => setSelected(Math.max(0, filtered.length - 1)),
5094
- "list.open-detail": () => setPanel("detail"),
5637
+ "list.open-detail": () => {
5638
+ if (currentSummary) stack.open(targetFromIssue(currentSummary));
5639
+ },
5095
5640
  "list.open-browser": openSelectedInBrowser,
5096
5641
  "list.comment": comments.open,
5097
5642
  "list.edit": editor.open,
@@ -5114,13 +5659,29 @@ function Dashboard({ ctx, logger }) {
5114
5659
  );
5115
5660
  if (!consumed && (input2 === "q" || key.escape)) setHelpOpen(false);
5116
5661
  };
5662
+ const openFocusedRelation = useCallback7(() => {
5663
+ const relation = relations.relations[relationsSelected];
5664
+ if (!relation) return;
5665
+ if (relation.target) {
5666
+ stack.push(targetFromIssue(relation.target));
5667
+ return;
5668
+ }
5669
+ if (!relation.targetKey) return;
5670
+ void ctx.issues.view(relation.targetKey).then((issue) => stack.push(targetFromIssue(issue))).catch((err) => {
5671
+ logger.error("open relation failed", { key: relation.targetKey, err });
5672
+ setStatusMessage(`open ${relation.targetKey}: ${err.message}`);
5673
+ });
5674
+ }, [relations.relations, relationsSelected, stack, ctx, logger]);
5117
5675
  const handleDetailKey = (input2, key) => {
5676
+ const relationsMode = detailMode === "relations";
5677
+ const relationCount = relations.relations.length;
5118
5678
  const detailHandlers = {
5119
- "detail.close": () => setPanel("list"),
5120
- "detail.scroll-down": () => detail.scrollBy(1),
5121
- "detail.scroll-down-alt": () => detail.scrollBy(1),
5122
- "detail.scroll-up": () => detail.scrollBy(-1),
5123
- "detail.scroll-up-alt": () => detail.scrollBy(-1),
5679
+ // esc pops the navigation stack: back to the issue we came from, or close.
5680
+ "detail.close": () => stack.pop(),
5681
+ "detail.scroll-down": relationsMode ? () => setRelationsSelected((s) => Math.min(relationCount - 1, s + 1)) : () => detail.scrollBy(1),
5682
+ "detail.scroll-down-alt": relationsMode ? () => setRelationsSelected((s) => Math.min(relationCount - 1, s + 1)) : () => detail.scrollBy(1),
5683
+ "detail.scroll-up": relationsMode ? () => setRelationsSelected((s) => Math.max(0, s - 1)) : () => detail.scrollBy(-1),
5684
+ "detail.scroll-up-alt": relationsMode ? () => setRelationsSelected((s) => Math.max(0, s - 1)) : () => detail.scrollBy(-1),
5124
5685
  "detail.page-down": () => detail.scrollBy(detailViewportRows),
5125
5686
  "detail.page-up": () => detail.scrollBy(-detailViewportRows),
5126
5687
  "detail.top": detail.scrollTop,
@@ -5128,10 +5689,24 @@ function Dashboard({ ctx, logger }) {
5128
5689
  "detail.open-browser": openSelectedInBrowser,
5129
5690
  "detail.comment": comments.open,
5130
5691
  "detail.edit": editor.open,
5692
+ // Toggle the activity / relations bodies; toggling back returns to the
5693
+ // description. Reset the scroll so the new body starts at the top.
5694
+ "detail.activity": () => {
5695
+ setDetailMode((mode) => mode === "activity" ? "detail" : "activity");
5696
+ detail.scrollTop();
5697
+ },
5698
+ "detail.relations": () => {
5699
+ setDetailMode((mode) => mode === "relations" ? "detail" : "relations");
5700
+ setRelationsSelected(0);
5701
+ detail.scrollTop();
5702
+ },
5703
+ // enter only acts in the relations body, opening the focused relation.
5704
+ "detail.relation-open": relationsMode ? openFocusedRelation : () => {
5705
+ },
5131
5706
  "global.help": () => setHelpOpen((open2) => !open2),
5132
5707
  "global.refresh": () => void load(true),
5133
5708
  "global.refresh-all": () => viewsData.refreshAll(),
5134
- "global.quit": () => setPanel("list")
5709
+ "global.quit": () => stack.close()
5135
5710
  };
5136
5711
  dispatch(ctx.keybindings, ["detail", "global"], detailHandlers, input2, key);
5137
5712
  };
@@ -5141,7 +5716,7 @@ function Dashboard({ ctx, logger }) {
5141
5716
  [editor.active, editor.handleKey],
5142
5717
  [comments.active, comments.handleKey],
5143
5718
  [helpOpen, handleHelpKey],
5144
- [panel === "detail", handleDetailKey],
5719
+ [detailOpen, handleDetailKey],
5145
5720
  [filtering, handleFilterKey]
5146
5721
  ];
5147
5722
  useInput2((input2, key) => {
@@ -5149,7 +5724,7 @@ function Dashboard({ ctx, logger }) {
5149
5724
  if (route) return route[1](input2, key);
5150
5725
  dispatch(ctx.keybindings, ["global", "list", "view", "filter"], handlers, input2, key);
5151
5726
  });
5152
- const current = detail.detailed ?? currentSummary;
5727
+ const current = detail.detailed ?? (stack.canGoBack ? void 0 : currentSummary);
5153
5728
  const statusBarBase = {
5154
5729
  profile: ctx.runtime.profile_name,
5155
5730
  workspace: ctx.runtime.profile.server.workspace_slug,
@@ -5162,13 +5737,17 @@ function Dashboard({ ctx, logger }) {
5162
5737
  total: issues.length,
5163
5738
  filtering: Boolean(filter)
5164
5739
  });
5165
- const isDetail = panel === "detail";
5740
+ const isDetail = detailOpen;
5166
5741
  const overlay = renderActiveOverlay({
5167
5742
  transition,
5168
5743
  creator,
5169
5744
  editor,
5170
5745
  comments,
5171
5746
  detail,
5747
+ activity,
5748
+ relations,
5749
+ relationsSelected,
5750
+ detailMode,
5172
5751
  helpOpen,
5173
5752
  isDetail,
5174
5753
  currentSummary,
@@ -5198,6 +5777,7 @@ function Dashboard({ ctx, logger }) {
5198
5777
  filtering,
5199
5778
  viewportRows,
5200
5779
  layout: activeLayout,
5780
+ sort: activeSort,
5201
5781
  loading,
5202
5782
  statusBar: /* @__PURE__ */ jsx13(StatusBar, { ...statusBarBase, loading, position: listPosition })
5203
5783
  }
@@ -5225,6 +5805,7 @@ function ListLayout(props) {
5225
5805
  viewportRows: props.viewportRows,
5226
5806
  width: props.narrow ? props.width : props.width - SIDE_PANEL_WIDTH,
5227
5807
  layout: props.layout,
5808
+ sort: props.sort,
5228
5809
  loading: props.loading
5229
5810
  }
5230
5811
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plc-cli",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "description": "CLI and TUI for Plane (Cloud and self-hosted), inspired by gh and gh dash.",
5
5
  "license": "MIT",
6
6
  "author": "Bruno Mariano",