@qzsy/dsh-command-palette 1.4.0 → 1.5.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.
package/README.md CHANGED
@@ -77,6 +77,16 @@ initials, or any mixed form.
77
77
  movement inside the query. IME composition is never intercepted, so pinyin
78
78
  candidate selection keeps working. Focus returns to whatever had it before
79
79
  opening.
80
+ - **Mobile**: at `(max-width: 720px), (pointer: coarse) and (max-width: 1024px)`
81
+ the palette becomes a bottom sheet at 90% of the layer height. It opens
82
+ without focusing the search input, so the soft keyboard stays closed until
83
+ the user taps the field. All keyboard hints (Esc key, row shortcuts, footer
84
+ key legend) are hidden; the footer only remains for the subagent toggle.
85
+ - **Unread count**: the sidebar row shows the number of Sessions that finished
86
+ successfully but have not been viewed (the green 已完成 badge; failed,
87
+ archived, blank and subagent Sessions are excluded). The collapsed rail shows
88
+ a dot instead. The always-mounted overlay computes the count and mirrors it
89
+ into a plugin-local store read by the sidebar glyph.
80
90
  - **Sidebar entry**: the *命令面板 / Command palette* row joins the global panel
81
91
  stack directly below 自动化任务 / Automations (glyph-only in the collapsed
82
92
  rail). Choosing it opens the overlay without switching the main panel: the row
package/dist/client.js CHANGED
@@ -89,6 +89,7 @@ window.__ModuleLoader__.load({
89
89
  'footer.filter': '切换分类',
90
90
  'footer.close': '关闭',
91
91
  'trigger.label': '命令面板',
92
+ 'trigger.unread': '{n} 个会话已完成,尚未查看',
92
93
  'trigger.title': '打开命令面板',
93
94
  'time.now': '刚刚',
94
95
  'time.short.m': '{n}m',
@@ -155,6 +156,7 @@ window.__ModuleLoader__.load({
155
156
  'footer.filter': 'Change filter',
156
157
  'footer.close': 'Close',
157
158
  'trigger.label': 'Command palette',
159
+ 'trigger.unread': '{n} sessions finished, not yet viewed',
158
160
  'trigger.title': 'Open command palette',
159
161
  'time.now': 'now',
160
162
  'time.short.m': '{n}m',
@@ -764,7 +766,7 @@ window.__ModuleLoader__.load({
764
766
  const CSS = [
765
767
  '.dshcp-layer{position:fixed;inset:0;z-index:1200;display:flex;justify-content:center;align-items:flex-start;padding:min(12vh,120px) 20px 20px;pointer-events:none}',
766
768
  '.dshcp-backdrop{position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur);pointer-events:auto;animation:dshcp-fade .12s var(--ds-ease-out,ease-out)}',
767
- '.dshcp-panel{position:relative;pointer-events:auto;box-sizing:border-box;display:flex;flex-direction:column;width:min(680px,100%);max-height:min(600px,74vh);overflow:hidden;border:0.5px solid var(--dsw-alias-border-l1);border-radius:16px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-prominent);font-family:var(--dsw-font-family,inherit);animation:dshcp-pop .14s var(--ds-ease-out,ease-out)}',
769
+ '.dshcp-panel{position:relative;pointer-events:auto;box-sizing:border-box;display:flex;flex-direction:column;width:min(680px,100%);max-height:min(600px,74vh,100%);overflow:hidden;border:0.5px solid var(--dsw-alias-border-l1);border-radius:16px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-prominent);font-family:var(--dsw-font-family,inherit);animation:dshcp-pop .14s var(--ds-ease-out,ease-out)}',
768
770
  '.dshcp-panel:focus{outline:none}',
769
771
  '@keyframes dshcp-fade{from{opacity:0}to{opacity:1}}',
770
772
  '@keyframes dshcp-pop{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}',
@@ -818,7 +820,16 @@ window.__ModuleLoader__.load({
818
820
  '.dshcp-projectBadge{display:inline-flex;align-items:center;max-width:100%;height:19px;padding:0 7px;border-radius:999px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
819
821
  '[data-dshcp-reveal]{animation:dshcp-reveal 1.2s var(--ds-ease-out,ease-out)}',
820
822
  '@keyframes dshcp-reveal{0%{box-shadow:inset 0 0 0 2px var(--dsw-alias-state-business-primary)}100%{box-shadow:inset 0 0 0 2px transparent}}',
821
- '@media (prefers-reduced-motion:reduce){.dshcp-backdrop,.dshcp-panel{animation:none}.dshcp-badge[data-tone=running] .dshcp-badgeDot{animation:none}[data-dshcp-reveal]{animation:none;box-shadow:inset 0 0 0 2px var(--dsw-alias-state-business-primary)}}'
823
+ '.dshcp-layer[data-mobile=true]{align-items:flex-end;padding:0}',
824
+ '.dshcp-layer[data-mobile=true] .dshcp-panel{width:100%;height:90%;max-height:90%;border-bottom:none;border-radius:16px 16px 0 0;padding-bottom:env(safe-area-inset-bottom,0px);animation:dshcp-sheet .2s var(--ds-ease-out,ease-out)}',
825
+ '@keyframes dshcp-sheet{from{transform:translateY(100%)}to{transform:none}}',
826
+ '.dshcp-layer[data-mobile=true] .dshcp-input{font-size:16px}',
827
+ '.dshcp-layer[data-mobile=true] .dshcp-tabs{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}',
828
+ '.dshcp-layer[data-mobile=true] .dshcp-tab{flex:none}',
829
+ '.dshcp-glyph{position:relative;display:inline-flex}',
830
+ '.dshcp-glyphDot{position:absolute;top:-2px;right:-3px;width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-state-success-primary)}',
831
+ '.dshcp-unreadCount{position:absolute;right:10px;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:20px;height:18px;padding:0 6px;border-radius:999px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-state-success-primary);font-size:11px;line-height:16px;font-weight:600;font-variant-numeric:tabular-nums;pointer-events:none}',
832
+ '@media (prefers-reduced-motion:reduce){.dshcp-backdrop,.dshcp-panel,.dshcp-layer[data-mobile=true] .dshcp-panel{animation:none}.dshcp-badge[data-tone=running] .dshcp-badgeDot{animation:none}[data-dshcp-reveal]{animation:none;box-shadow:inset 0 0 0 2px var(--dsw-alias-state-business-primary)}}'
822
833
  ].join('');
823
834
  const STYLE_ATTR = 'data-dsh-command-palette';
824
835
  function installStyles() {
@@ -840,8 +851,63 @@ window.__ModuleLoader__.load({
840
851
  * Glyph rendered inside the sidebar's global panel row. The shell owns the
841
852
  * row, label, tooltip and rail sizing; this component only supplies artwork.
842
853
  */
843
- function PalettePanelGlyph({ size }) {
844
- return h(IconSearch, { size });
854
+ function PalettePanelGlyph({ size, t }) {
855
+ const count = useUnreadCount();
856
+ const wide = size !== 18;
857
+ const label = count > 0 ? t('trigger.unread', { n: count }) : undefined;
858
+ return h(React.Fragment, null,
859
+ h('span', { className: 'dshcp-glyph', title: label },
860
+ h(IconSearch, { size }),
861
+ !wide && count > 0 ? h('span', { className: 'dshcp-glyphDot', 'aria-hidden': 'true' }) : null),
862
+ wide && count > 0 ? h('span', { className: 'dshcp-unreadCount', 'aria-label': label }, count > 99 ? '99+' : String(count)) : null);
863
+ }
864
+
865
+ /**
866
+ * Unread completions shown on the sidebar row. The panel-list slot is not
867
+ * documented to receive the session status hook, so the always-mounted
868
+ * overlay computes the count and mirrors it into this plugin-local store.
869
+ */
870
+ const unreadStore = createStore({ count: 0 });
871
+ function useUnreadCount() {
872
+ const read = () => unreadStore.getSnapshot().count;
873
+ if (typeof React.useSyncExternalStore === 'function') return React.useSyncExternalStore(unreadStore.subscribe, read, read);
874
+ const [count, setCount] = useState(read);
875
+ useEffect(() => unreadStore.subscribe(() => setCount(read())), []);
876
+ return count;
877
+ }
878
+
879
+ /**
880
+ * Sessions that finished successfully and the user has not opened yet: the
881
+ * same rows that carry the green 已完成 badge in the default palette view
882
+ * (archived, blank and subagent Sessions excluded; failures count as 出错).
883
+ */
884
+ function countUnreadCompletions(sessions, workspaces, statuses, failures) {
885
+ const archived = new Set(workspaces?.archivedSessionIds ?? []);
886
+ let count = 0;
887
+ for (const row of Object.values(sessions?.byId ?? {})) {
888
+ if (row === undefined || row === null || row.blank === true || archived.has(row.id) || row.origin === 'subagent') continue;
889
+ const state = sessionState(row, statuses?.get?.(row.id), failures?.get?.(row.id));
890
+ if (state !== null && state.tone === 'done') count += 1;
891
+ }
892
+ return count;
893
+ }
894
+
895
+ /** Breakpoint shared with the quick-commands plugin. */
896
+ const MOBILE_QUERY = '(max-width: 720px), (pointer: coarse) and (max-width: 1024px)';
897
+ function isMobileLayout() {
898
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(MOBILE_QUERY).matches;
899
+ }
900
+ function useMobileLayout() {
901
+ const [mobile, setMobile] = useState(isMobileLayout);
902
+ useEffect(() => {
903
+ if (typeof window.matchMedia !== 'function') return undefined;
904
+ const query = window.matchMedia(MOBILE_QUERY);
905
+ const update = () => setMobile(query.matches);
906
+ update();
907
+ query.addEventListener?.('change', update);
908
+ return () => query.removeEventListener?.('change', update);
909
+ }, []);
910
+ return mobile;
845
911
  }
846
912
  //#endregion
847
913
 
@@ -1046,8 +1112,22 @@ window.__ModuleLoader__.load({
1046
1112
  function Palette(props) {
1047
1113
  const { usePalette } = props;
1048
1114
  const open = usePalette((state) => state.open);
1049
- if (!open) return null;
1050
- return h(PaletteDialog, props);
1115
+ return h(React.Fragment, null,
1116
+ h(UnreadMirror, props),
1117
+ open ? h(PaletteDialog, props) : null);
1118
+ }
1119
+
1120
+ function UnreadMirror({ useSessions, useWorkspaces, useSessionStatus, useSessionFailures }) {
1121
+ const sessions = useSessions((value) => value);
1122
+ const workspaces = useWorkspaces((value) => value);
1123
+ const statuses = useSessionStatus((value) => value);
1124
+ const failures = useSessionFailures((value) => value) ?? EMPTY_FAILURES;
1125
+ const count = useMemo(() => countUnreadCompletions(sessions, workspaces, statuses, failures), [sessions, workspaces, statuses, failures]);
1126
+ useEffect(() => {
1127
+ if (unreadStore.getSnapshot().count !== count) unreadStore.set({ count });
1128
+ }, [count]);
1129
+ useEffect(() => () => unreadStore.set({ count: 0 }), []);
1130
+ return null;
1051
1131
  }
1052
1132
 
1053
1133
  function PaletteDialog({ usePalette, useShortcutCatalog, useSessions, useWorkspaces, useSessionStatus, useSessionFailures, actions, t }) {
@@ -1060,6 +1140,7 @@ window.__ModuleLoader__.load({
1060
1140
  const [includeSubagents, setIncludeSubagents] = useState(false);
1061
1141
  const [gitEntries, setGitEntries] = useState(() => gitStatusCache.entries);
1062
1142
  const inputRef = useRef(null);
1143
+ const mobile = useMobileLayout();
1063
1144
  const panelRef = useRef(null);
1064
1145
  const activeRowRef = useRef(null);
1065
1146
  const sessions = useSessions((value) => value);
@@ -1118,7 +1199,9 @@ window.__ModuleLoader__.load({
1118
1199
 
1119
1200
  useEffect(() => {
1120
1201
  const frame = window.requestAnimationFrame(() => {
1121
- inputRef.current?.focus();
1202
+ // Phones open without the soft keyboard: focus the sheet, not the input.
1203
+ const target = isMobileLayout() ? panelRef.current : inputRef.current;
1204
+ target?.focus({ preventScroll: true });
1122
1205
  });
1123
1206
  return () => window.cancelAnimationFrame(frame);
1124
1207
  }, []);
@@ -1322,7 +1405,7 @@ window.__ModuleLoader__.load({
1322
1405
  item.badge !== null && item.badge !== undefined
1323
1406
  ? h('span', { className: 'dshcp-badge', 'data-tone': item.badge.tone, title: item.badge.title }, h('span', { className: 'dshcp-badgeDot' }), item.badge.label)
1324
1407
  : null,
1325
- item.keys !== undefined && item.keys.length > 0
1408
+ !mobile && item.keys !== undefined && item.keys.length > 0
1326
1409
  ? h('span', { className: 'dshcp-keys' }, item.keys.map((key, keyIndex) => h('kbd', { key: keyIndex, className: 'dshcp-key' }, key)))
1327
1410
  : item.meta !== undefined && item.meta !== '' ? h('span', { className: 'dshcp-meta' }, item.meta) : null));
1328
1411
  }
@@ -1334,11 +1417,28 @@ window.__ModuleLoader__.load({
1334
1417
  rows.push(h('div', { key: 'files-error', className: 'dshcp-status' }, t('files.error')));
1335
1418
  }
1336
1419
 
1420
+ const subagentToggle = subagentCount > 0 && (filter === 'all' || filter === 'sessions')
1421
+ ? h('button', {
1422
+ className: 'dshcp-toggle',
1423
+ type: 'button',
1424
+ title: includeSubagents ? t('subagents.hide', { n: subagentCount }) : t('subagents.show', { n: subagentCount }),
1425
+ onMouseDown: (event) => event.preventDefault(),
1426
+ onClick: () => setIncludeSubagents((value) => !value)
1427
+ }, includeSubagents ? t('subagents.hide', { n: subagentCount }) : t('subagents.show', { n: subagentCount }))
1428
+ : null;
1429
+ // Touch layouts have no keyboard to hint at.
1430
+ const footerHints = mobile ? null : [
1431
+ h('span', { key: 'select', className: 'dshcp-hint' }, h('kbd', { className: 'dshcp-hintKey' }, '↑'), h('kbd', { className: 'dshcp-hintKey' }, '↓'), t('footer.select')),
1432
+ h('span', { key: 'open', className: 'dshcp-hint' }, h('kbd', { className: 'dshcp-hintKey' }, '↵'), t('footer.open')),
1433
+ h('span', { key: 'filter', className: 'dshcp-hint' }, h('kbd', { className: 'dshcp-hintKey' }, '←'), h('kbd', { className: 'dshcp-hintKey' }, '→'), t('footer.filter')),
1434
+ h('span', { key: 'close', className: 'dshcp-hint' }, h('kbd', { className: 'dshcp-hintKey' }, 'Esc'), t('footer.close'))
1435
+ ];
1436
+ const showToggle = subagentCount > 0 && (filter === 'all' || filter === 'sessions');
1337
1437
  const placeholder = filter === 'files'
1338
1438
  ? currentSessionId === undefined ? t('empty.files.noSession') : t('placeholder.files', { name: currentProject ?? t('item.noWorkspace') })
1339
1439
  : t('placeholder');
1340
1440
  return h(React.Fragment, null,
1341
- h('div', { className: 'dshcp-layer' },
1441
+ h('div', { className: 'dshcp-layer', 'data-mobile': mobile },
1342
1442
  h('div', { className: 'dshcp-backdrop', onMouseDown: close }),
1343
1443
  h('div', {
1344
1444
  className: 'dshcp-panel',
@@ -1367,7 +1467,7 @@ window.__ModuleLoader__.load({
1367
1467
  spellCheck: false,
1368
1468
  onChange: (event) => setQuery(event.target.value)
1369
1469
  }),
1370
- h('span', { className: 'dshcp-escKey' }, 'Esc')),
1470
+ mobile ? null : h('span', { className: 'dshcp-escKey' }, 'Esc')),
1371
1471
  h('div', { className: 'dshcp-tabs', role: 'tablist' },
1372
1472
  filterButton('all', t('tab.all')),
1373
1473
  filterButton('sessions', t('tab.sessions')),
@@ -1375,12 +1475,12 @@ window.__ModuleLoader__.load({
1375
1475
  filterButton('files', t('tab.files')),
1376
1476
  filterButton('actions', t('tab.actions'))),
1377
1477
  h('div', { className: 'dshcp-list', id: 'dshcp-listbox', role: 'listbox', 'aria-label': t('command.open') }, rows),
1378
- h('div', { className: 'dshcp-footer' },
1379
- h('span', { className: 'dshcp-hint' }, h('kbd', { className: 'dshcp-hintKey' }, '↑'), h('kbd', { className: 'dshcp-hintKey' }, '↓'), t('footer.select')),
1380
- h('span', { className: 'dshcp-hint' }, h('kbd', { className: 'dshcp-hintKey' }, '↵'), t('footer.open')),
1381
- h('span', { className: 'dshcp-hint' }, h('kbd', { className: 'dshcp-hintKey' }, '←'), h('kbd', { className: 'dshcp-hintKey' }, '→'), t('footer.filter')),
1382
- h('span', { className: 'dshcp-hint' }, h('kbd', { className: 'dshcp-hintKey' }, 'Esc'), t('footer.close')),
1383
- subagentCount > 0 && (filter === 'all' || filter === 'sessions')
1478
+ mobile && !showToggle ? null : h('div', { className: 'dshcp-footer' },
1479
+ mobile ? null : h('span', { className: 'dshcp-hint' }, h('kbd', { className: 'dshcp-hintKey' }, '↑'), h('kbd', { className: 'dshcp-hintKey' }, '↓'), t('footer.select')),
1480
+ mobile ? null : h('span', { className: 'dshcp-hint' }, h('kbd', { className: 'dshcp-hintKey' }, '↵'), t('footer.open')),
1481
+ mobile ? null : h('span', { className: 'dshcp-hint' }, h('kbd', { className: 'dshcp-hintKey' }, '←'), h('kbd', { className: 'dshcp-hintKey' }, '→'), t('footer.filter')),
1482
+ mobile ? null : h('span', { className: 'dshcp-hint' }, h('kbd', { className: 'dshcp-hintKey' }, 'Esc'), t('footer.close')),
1483
+ showToggle
1384
1484
  ? h('button', {
1385
1485
  className: 'dshcp-toggle',
1386
1486
  type: 'button',
@@ -1637,7 +1737,7 @@ window.__ModuleLoader__.load({
1637
1737
  order: 20,
1638
1738
  label: () => t('trigger.label'),
1639
1739
  locale: NS
1640
- }, PalettePanelGlyph));
1740
+ }, (props) => h(PalettePanelGlyph, { ...props, t })));
1641
1741
  interceptSidebarSelection(ctx, actions);
1642
1742
  adoptShortcut(ctx);
1643
1743
  }
@@ -1656,7 +1756,8 @@ window.__ModuleLoader__.load({
1656
1756
  sessionBadge,
1657
1757
  summarizeSessionRows,
1658
1758
  sessionItems,
1659
- workspaceItems
1759
+ workspaceItems,
1760
+ countUnreadCompletions
1660
1761
  };
1661
1762
 
1662
1763
  return { inject, apply, __test };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qzsy/dsh-command-palette",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Command+K palette for recent sessions, workspaces, files, and actions with pinyin search and live session status.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -245,6 +245,7 @@ function runChecks({ plugin, registration, source }) {
245
245
 
246
246
  check('surface: __test exposes the documented helpers', () => {
247
247
  const expected = [
248
+ 'countUnreadCompletions',
248
249
  'createFailureStore',
249
250
  'installFailureFeed',
250
251
  'normalizeFailureMessage',
@@ -756,6 +757,18 @@ function runChecks({ plugin, registration, source }) {
756
757
  equal(row.badge, { tone: 'running', label: '工作中' }, 'badge');
757
758
  equal(row.status, { running: true }, 'status payload');
758
759
  });
760
+
761
+ check('unread: counts unread successes only, skipping failed, archived, blank and subagent rows', () => {
762
+ const sessions = { byId: {
763
+ a: { id: 'a' }, b: { id: 'b' }, c: { id: 'c' }, d: { id: 'd', blank: true },
764
+ e: { id: 'e', origin: 'subagent' }, f: { id: 'f' }, g: { id: 'g' }
765
+ } };
766
+ const unread = { completionUnread: true };
767
+ const statuses = new Map([['a', unread], ['b', unread], ['c', unread], ['d', unread], ['e', unread], ['f', { completionUnread: true, running: true }], ['g', {}]]);
768
+ const failures = new Map([['b', { message: 'x', at: NOW }]]);
769
+ equal(api.countUnreadCompletions(sessions, { archivedSessionIds: ['c'] }, statuses, failures), 1, 'unread count');
770
+ equal(api.countUnreadCompletions(undefined, undefined, undefined, undefined), 0, 'empty snapshot');
771
+ });
759
772
  }
760
773
 
761
774
  // ---------------------------------------------------------------------------