@oar-sh/oar 0.9.16 → 0.9.17

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 (75) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +220 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  7. package/server/claude-worker/claude-session-process.mjs +127 -29
  8. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  9. package/server/claude-worker/claude-session-worker.mjs +2 -253
  10. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  11. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  12. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  13. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  14. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  15. package/server/copilot-worker/copilot-sdk-session-process.mjs +71 -5
  16. package/server/db-schema.mjs +63 -0
  17. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  18. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  19. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  20. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  21. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  22. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  23. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  24. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  25. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  26. package/server/public/app/api-client.js +53 -0
  27. package/server/public/app/bootstrap.js +101 -13
  28. package/server/public/app/context-tier-options.mjs +4 -0
  29. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  30. package/server/public/app/conversation-view.js +2 -0
  31. package/server/public/app/deepseek-settings-ui.js +342 -0
  32. package/server/public/app/inbox-ui.js +14 -3
  33. package/server/public/app/journal-view.js +31 -3
  34. package/server/public/app/model-selector-options.mjs +11 -0
  35. package/server/public/app/model-vision-support.mjs +2 -0
  36. package/server/public/app/no-conversation-state.mjs +1 -1
  37. package/server/public/app/plan-usage-view.mjs +165 -13
  38. package/server/public/app/provider-readiness.mjs +4 -1
  39. package/server/public/app/relay-error-ctas.mjs +8 -0
  40. package/server/public/app/settings-modal.js +11 -0
  41. package/server/public/app/settings-tabs.js +1 -1
  42. package/server/public/app/socket-handlers.js +10 -0
  43. package/server/public/index.html +408 -33
  44. package/server/repositories/conversation-group-repository.mjs +13 -6
  45. package/server/routes/conversation-group-routes.mjs +2 -1
  46. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  47. package/server/routes/messages-routes.mjs +192 -15
  48. package/server/routes/sessions-routes.mjs +364 -55
  49. package/server/server-runtime.mjs +123 -10
  50. package/server/services/about-service.mjs +1 -0
  51. package/server/services/claude-session-leftovers.mjs +226 -0
  52. package/server/services/deepseek-balance-service.mjs +102 -0
  53. package/server/services/deepseek-settings-service.mjs +380 -0
  54. package/server/services/deepseek-usage-import.mjs +471 -0
  55. package/server/services/model-variant-catalog-service.mjs +1 -0
  56. package/server/services/plan-usage-contract.mjs +100 -5
  57. package/server/services/plan-usage-deepseek.mjs +931 -0
  58. package/server/services/plan-usage-service.mjs +431 -0
  59. package/server/services/provider-model-selection.mjs +1 -0
  60. package/server/services/provider-reasoning-effort.mjs +15 -2
  61. package/server/services/remote-relay-dispatcher.mjs +2 -1
  62. package/server/services/session-worker-launch-service.mjs +233 -82
  63. package/server/services/worker-secret-redaction.mjs +318 -0
  64. package/shared/context-window-fallbacks.mjs +3 -0
  65. package/shared/deepseek-api.mjs +137 -0
  66. package/shared/deepseek-models.mjs +114 -0
  67. package/shared/deepseek-pricing.mjs +146 -0
  68. package/shared/host-claude-session-env.mjs +101 -0
  69. package/shared/model-id.mjs +7 -0
  70. package/shared/preview-tool-core.mjs +1 -1
  71. package/shared/provider-routing.mjs +2 -2
  72. package/shared/remote-relay-contract.mjs +1 -1
  73. package/shared/steer-settle-failure.mjs +1 -0
  74. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  75. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -295,6 +295,16 @@
295
295
  --provider-cursor: #a78bfa;
296
296
  --provider-grok: #e879f9;
297
297
  --provider-claude-cloud: #7dd3fc;
298
+ /* DeepSeek's blue, toward indigo so it reads apart from Copilot's. */
299
+ --provider-deepseek: #8b9dff;
300
+ /* The usage charts' series, in a fixed order (blue, orange, green,
301
+ amber; grey for "other"), stepped for the dark surface and checked
302
+ for colour-vision deficiency and contrast against it. */
303
+ --plan-usage-series-1: #3987e5;
304
+ --plan-usage-series-2: #d95926;
305
+ --plan-usage-series-3: #199e70;
306
+ --plan-usage-series-4: #c98500;
307
+ --plan-usage-series-5: #8b949e;
298
308
  --viewport-top-offset: 0px;
299
309
  --header-safe-top: max(env(safe-area-inset-top, 0px), var(--viewport-top-offset, 0px));
300
310
  --chat-header-top-base-desktop: 14px;
@@ -359,6 +369,13 @@
359
369
  --provider-cursor: #6b3fd4;
360
370
  --provider-grok: #a21caf;
361
371
  --provider-claude-cloud: #0369a1;
372
+ --provider-deepseek: #3b5bdb;
373
+ /* The same series order, stepped for the white surface. */
374
+ --plan-usage-series-1: #2a78d6;
375
+ --plan-usage-series-2: #eb6834;
376
+ --plan-usage-series-3: #1baf7a;
377
+ --plan-usage-series-4: #eda100;
378
+ --plan-usage-series-5: #656d76;
362
379
  --inline-code-bg: #eff1f3;
363
380
  }
364
381
  :root {
@@ -987,6 +1004,7 @@
987
1004
  .conv-provider-indicator[data-provider="claude"] { color: var(--provider-claude); }
988
1005
  .conv-provider-indicator[data-provider="cursor"] { color: var(--provider-cursor); }
989
1006
  .conv-provider-indicator[data-provider="grok"] { color: var(--provider-grok); }
1007
+ .conv-provider-indicator[data-provider="deepseek"] { color: var(--provider-deepseek); }
990
1008
  .conv-provider-indicator[data-provider="claude-cloud"] { color: var(--provider-claude-cloud); }
991
1009
  .conv-item.worker-ui-offline .conv-title,
992
1010
  #chat-title[data-ui-state="offline"] { color: var(--conversation-title-offline); }
@@ -1455,6 +1473,74 @@
1455
1473
  font-size: var(--fs-meta-md);
1456
1474
  width: min(260px, 100%);
1457
1475
  }
1476
+ /* The DeepSeek tab's key, base URL and monthly budget take the text
1477
+ inputs' look (an input left to the browser is a white box in dark). */
1478
+ #settings-provider-panel-deepseek input[type="password"],
1479
+ #settings-provider-panel-deepseek input[type="url"],
1480
+ #settings-provider-panel-deepseek input[type="number"] {
1481
+ background: var(--bg);
1482
+ color: var(--text);
1483
+ border: 1px solid var(--border);
1484
+ border-radius: 6px;
1485
+ padding: 6px 12px;
1486
+ outline: none;
1487
+ font-size: var(--fs-meta-md);
1488
+ width: min(360px, 100%);
1489
+ }
1490
+ /* A select is as wide as its longest option (the commit attribution's
1491
+ runs to ~290 px): kept inside the panel on a narrow phone. */
1492
+ #settings-provider-panel-deepseek select {
1493
+ max-width: 100%;
1494
+ }
1495
+ /* "No longer used" under the starting balance: the warning look of the
1496
+ composer's model banner, apart from the help text above it. */
1497
+ #deepseek-starting-balance-status {
1498
+ background: var(--bg2);
1499
+ background: color-mix(in srgb, var(--warn) 14%, var(--bg2));
1500
+ border: 1px solid var(--warn);
1501
+ color: var(--warn);
1502
+ border-radius: 8px;
1503
+ padding: 6px 10px;
1504
+ margin-top: 6px;
1505
+ }
1506
+ /* Import usage export: the two file pickers in the panel's colours, their
1507
+ button drawn like the panel's other buttons. */
1508
+ .deepseek-usage-import {
1509
+ margin-top: 14px;
1510
+ padding-top: 12px;
1511
+ border-top: 1px solid var(--border);
1512
+ display: flex;
1513
+ flex-direction: column;
1514
+ gap: 6px;
1515
+ min-width: 0;
1516
+ }
1517
+ .deepseek-usage-import-files {
1518
+ display: grid;
1519
+ grid-template-columns: auto minmax(0, 1fr);
1520
+ gap: 6px 10px;
1521
+ align-items: center;
1522
+ }
1523
+ .deepseek-usage-import input[type="file"] {
1524
+ color: var(--text);
1525
+ font-size: var(--fs-meta-md);
1526
+ min-width: 0;
1527
+ max-width: 100%;
1528
+ }
1529
+ .deepseek-usage-import input[type="file"]::file-selector-button {
1530
+ border: 1px solid var(--border);
1531
+ background: var(--bg2);
1532
+ color: var(--text);
1533
+ border-radius: 8px;
1534
+ height: 30px;
1535
+ padding: 0 10px;
1536
+ margin-right: 8px;
1537
+ font: inherit;
1538
+ cursor: pointer;
1539
+ }
1540
+ .deepseek-usage-import-status:empty { display: none; }
1541
+ @media (max-width: 520px) {
1542
+ .deepseek-usage-import-files { grid-template-columns: minmax(0, 1fr); }
1543
+ }
1458
1544
  .settings-row input[type="checkbox"] {
1459
1545
  width: 18px;
1460
1546
  height: 18px;
@@ -2863,6 +2949,25 @@
2863
2949
  padding: 2px 0;
2864
2950
  }
2865
2951
  .plan-usage-details summary:hover { color: var(--text); }
2952
+ /* A card's figures (plan-usage-view.mjs renderFact): label and hint on the
2953
+ left, the value on the right, one row each. */
2954
+ .plan-usage-facts {
2955
+ display: grid;
2956
+ gap: 8px;
2957
+ margin: 12px 0 0;
2958
+ padding-top: 10px;
2959
+ border-top: 1px solid var(--border);
2960
+ }
2961
+ .plan-usage-fact {
2962
+ display: flex;
2963
+ align-items: baseline;
2964
+ justify-content: space-between;
2965
+ gap: 12px;
2966
+ font-size: 0.84rem;
2967
+ }
2968
+ .plan-usage-fact dt { display: flex; flex-direction: column; gap: 1px; min-width: 0; font-weight: 500; }
2969
+ .plan-usage-fact-hint { font-size: 0.72rem; font-weight: 400; color: var(--muted); }
2970
+ .plan-usage-fact dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
2866
2971
  .plan-usage-detail-note {
2867
2972
  font-size: 0.74rem;
2868
2973
  color: var(--muted);
@@ -2872,14 +2977,22 @@
2872
2977
  .plan-usage-detail-table { width: 100%; border-collapse: collapse; margin-top: 6px; }
2873
2978
  .plan-usage-detail-table td { padding: 5px 2px; border-bottom: 1px solid var(--border); }
2874
2979
  .plan-usage-detail-table tr:last-child td { border-bottom: none; }
2875
- .plan-usage-detail-label { display: flex; flex-direction: column; gap: 2px; }
2876
- .plan-usage-detail-hint { font-size: 0.7rem; color: var(--muted); }
2980
+ /* The label cell stays a table cell (a flex cell does not stretch to the
2981
+ row, and its border then steps against the value's): the hint goes
2982
+ under the label as a block. */
2983
+ .plan-usage-detail-hint { display: block; margin-top: 2px; font-size: 0.7rem; color: var(--muted); }
2877
2984
  .plan-usage-detail-value {
2878
2985
  text-align: right;
2879
2986
  font-variant-numeric: tabular-nums;
2880
2987
  white-space: nowrap;
2881
2988
  vertical-align: top;
2882
2989
  }
2990
+ /* On a phone a long value (tokens by model) wraps instead of pushing the
2991
+ card wider than the screen. */
2992
+ @media (max-width: 520px) {
2993
+ .plan-usage-details .plan-usage-detail-value { white-space: normal; }
2994
+ }
2995
+ .plan-usage-detail-part { white-space: nowrap; }
2883
2996
  .plan-usage-card-foot {
2884
2997
  display: flex;
2885
2998
  align-items: center;
@@ -2892,6 +3005,145 @@
2892
3005
  }
2893
3006
  .plan-usage-link { color: var(--accent); text-decoration: none; }
2894
3007
  .plan-usage-link:hover { text-decoration: underline; }
3008
+ /* Usage charts (plan-usage-view.mjs renderChart): stacked columns and
3009
+ bars from the theme's series colours; text stays in the text colours. */
3010
+ .plan-usage-chart {
3011
+ margin: 12px 0 0;
3012
+ padding-top: 10px;
3013
+ border-top: 1px solid var(--border);
3014
+ }
3015
+ .plan-usage-chart-head {
3016
+ display: flex;
3017
+ align-items: baseline;
3018
+ justify-content: space-between;
3019
+ gap: 10px;
3020
+ flex-wrap: wrap;
3021
+ margin-bottom: 8px;
3022
+ }
3023
+ .plan-usage-chart-label { font-size: 0.88rem; font-weight: 600; }
3024
+ .plan-usage-chart-summary { font-size: 0.82rem; color: var(--text); font-variant-numeric: tabular-nums; }
3025
+ .plan-usage-chart-columns {
3026
+ position: relative;
3027
+ display: grid;
3028
+ grid-template-columns: repeat(var(--plan-usage-columns, 30), minmax(0, 1fr));
3029
+ gap: 2px;
3030
+ height: 72px;
3031
+ padding-bottom: 18px;
3032
+ border-bottom: 1px solid var(--border);
3033
+ }
3034
+ .plan-usage-chart-col {
3035
+ position: relative;
3036
+ display: flex;
3037
+ align-items: flex-end;
3038
+ min-width: 0;
3039
+ height: 100%;
3040
+ border-radius: 2px;
3041
+ outline: none;
3042
+ }
3043
+ .plan-usage-chart-col:hover, .plan-usage-chart-col:focus-visible { background: var(--bg3); }
3044
+ .plan-usage-chart-col:focus-visible { box-shadow: 0 0 0 2px var(--accent); }
3045
+ .plan-usage-chart-stack {
3046
+ display: flex;
3047
+ flex-direction: column-reverse;
3048
+ width: 100%;
3049
+ height: 100%;
3050
+ justify-content: flex-start;
3051
+ }
3052
+ .plan-usage-chart-col-empty .plan-usage-chart-stack::before {
3053
+ content: '';
3054
+ display: block;
3055
+ height: 2px;
3056
+ background: var(--border);
3057
+ }
3058
+ .plan-usage-chart-columns .plan-usage-chart-seg {
3059
+ display: block;
3060
+ width: 100%;
3061
+ min-height: 2px;
3062
+ margin-top: 2px;
3063
+ }
3064
+ .plan-usage-chart-columns .plan-usage-chart-seg:last-child { border-radius: 4px 4px 0 0; }
3065
+ .plan-usage-chart-axis {
3066
+ position: absolute;
3067
+ left: 0;
3068
+ right: 0;
3069
+ bottom: 0;
3070
+ display: flex;
3071
+ justify-content: space-between;
3072
+ font-size: 0.68rem;
3073
+ color: var(--muted);
3074
+ }
3075
+ .plan-usage-chart-bars { display: flex; flex-direction: column; gap: 8px; }
3076
+ .plan-usage-chart-row {
3077
+ position: relative;
3078
+ display: grid;
3079
+ grid-template-columns: minmax(80px, 1fr) 3fr auto;
3080
+ align-items: center;
3081
+ gap: 10px;
3082
+ font-size: 0.82rem;
3083
+ border-radius: 4px;
3084
+ outline: none;
3085
+ }
3086
+ .plan-usage-chart-row:focus-visible { box-shadow: 0 0 0 2px var(--accent); }
3087
+ /* A model's name wraps on a narrow screen rather than being cut. */
3088
+ .plan-usage-chart-row-label { min-width: 0; line-height: 1.25; overflow-wrap: anywhere; }
3089
+ .plan-usage-chart-row-value { font-variant-numeric: tabular-nums; white-space: nowrap; }
3090
+ .plan-usage-chart-bar {
3091
+ display: flex;
3092
+ gap: 2px;
3093
+ height: 14px;
3094
+ border-radius: 4px;
3095
+ overflow: hidden;
3096
+ background: var(--bg3);
3097
+ }
3098
+ .plan-usage-chart-bar .plan-usage-chart-seg { display: block; height: 100%; min-width: 2px; }
3099
+ .plan-usage-chart-bar .plan-usage-chart-seg:first-child { border-radius: 4px 0 0 4px; }
3100
+ .plan-usage-chart-bar .plan-usage-chart-seg:last-child { border-radius: 0 4px 4px 0; }
3101
+ .plan-usage-chart-tip {
3102
+ display: none;
3103
+ position: absolute;
3104
+ bottom: calc(100% + 4px);
3105
+ left: 50%;
3106
+ transform: translateX(-50%);
3107
+ z-index: 2;
3108
+ max-width: 260px;
3109
+ padding: 4px 8px;
3110
+ border: 1px solid var(--border);
3111
+ border-radius: 6px;
3112
+ background: var(--bg2);
3113
+ color: var(--text);
3114
+ font-size: 0.74rem;
3115
+ line-height: 1.4;
3116
+ white-space: normal;
3117
+ text-align: center;
3118
+ pointer-events: none;
3119
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
3120
+ }
3121
+ .plan-usage-chart-col:hover .plan-usage-chart-tip,
3122
+ .plan-usage-chart-col:focus .plan-usage-chart-tip,
3123
+ .plan-usage-chart-row:hover .plan-usage-chart-tip,
3124
+ .plan-usage-chart-row:focus .plan-usage-chart-tip { display: block; }
3125
+ .plan-usage-chart-col .plan-usage-chart-tip { width: 180px; }
3126
+ .plan-usage-chart-col:first-child .plan-usage-chart-tip,
3127
+ .plan-usage-chart-col:nth-child(-n+6) .plan-usage-chart-tip { left: 0; transform: none; text-align: left; }
3128
+ .plan-usage-chart-col:nth-last-child(-n+7):not(.plan-usage-chart-axis) .plan-usage-chart-tip { left: auto; right: 0; transform: none; text-align: right; }
3129
+ .plan-usage-chart-legend {
3130
+ display: flex;
3131
+ flex-wrap: wrap;
3132
+ gap: 4px 14px;
3133
+ margin: 8px 0 0;
3134
+ padding: 0;
3135
+ list-style: none;
3136
+ font-size: 0.74rem;
3137
+ color: var(--muted);
3138
+ }
3139
+ .plan-usage-chart-legend li { display: inline-flex; align-items: center; gap: 5px; }
3140
+ .plan-usage-chart-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 3px; }
3141
+ /* A table wider than a narrow screen scrolls inside the card. */
3142
+ .plan-usage-chart-table { margin-top: 6px; font-size: 0.78rem; max-width: 100%; overflow-x: auto; }
3143
+ .plan-usage-chart-table summary { cursor: pointer; color: var(--muted); padding: 2px 0; }
3144
+ .plan-usage-chart-table summary:hover { color: var(--text); }
3145
+ .plan-usage-chart-table th { text-align: left; font-weight: 500; color: var(--muted); padding: 4px 2px; border-bottom: 1px solid var(--border); }
3146
+ .plan-usage-chart-table thead th:not(:first-child) { text-align: right; }
2895
3147
  /* About OAR: the plan-usage cards and tables, stacked. */
2896
3148
  .about-modal { display: flex; flex-direction: column; gap: 12px; }
2897
3149
  .about-section .plan-usage-card-head { margin-bottom: 4px; }
@@ -3186,6 +3438,7 @@
3186
3438
  #model-lock-note[data-provider="claude"] { color: var(--provider-claude); }
3187
3439
  #model-lock-note[data-provider="cursor"] { color: var(--provider-cursor); }
3188
3440
  #model-lock-note[data-provider="grok"] { color: var(--provider-grok); }
3441
+ #model-lock-note[data-provider="deepseek"] { color: var(--provider-deepseek); }
3189
3442
  #model-lock-note[data-provider="claude-cloud"] { color: var(--provider-claude-cloud); }
3190
3443
  /* Claude Cloud conversations (claude-cloud-conversation-ui.js): where the
3191
3444
  session runs and what it pushed, above the composer like the lock note.
@@ -6389,37 +6642,51 @@
6389
6642
  .conv-item.conv-drag-ghost { margin: -18px 0 0; }
6390
6643
  .conv-item.conv-drop-before::before,
6391
6644
  .conv-item.conv-drop-after::after { height: 4px; }
6645
+ /* Two rows on a phone: ☰, the title and the buttons on the first; the
6646
+ origin line, the chips and the folder on a second row across the whole
6647
+ header. The title column dissolves (display: contents) so its lines
6648
+ become the header's own flex items; ::after breaks the row after the
6649
+ buttons. The row gap is 0 and the second row's items carry their own
6650
+ top margin, so a header with nothing below the title stays one row. */
6392
6651
  #chat-header {
6393
6652
  padding: var(--chat-header-top-pad-mobile) 10px 10px;
6394
- gap: 6px 8px;
6395
- flex-wrap: nowrap;
6653
+ gap: 0 8px;
6654
+ flex-wrap: wrap;
6396
6655
  align-items: center;
6397
6656
  position: relative;
6398
6657
  z-index: 30;
6399
6658
  }
6400
- #chat-title-wrap {
6401
- flex: 1 1 auto;
6402
- width: auto;
6403
- min-width: 0;
6404
- flex-wrap: nowrap;
6405
- align-items: flex-start;
6406
- gap: 6px;
6407
- }
6408
- #chat-title-slot {
6409
- display: flex;
6410
- flex-direction: column;
6411
- flex: 1 1 auto;
6412
- width: auto;
6413
- margin: 0;
6414
- min-width: 0;
6659
+ #chat-header::after {
6660
+ content: '';
6661
+ order: 4;
6662
+ flex: 0 0 100%;
6663
+ height: 0;
6415
6664
  }
6665
+ #chat-title-wrap,
6666
+ #chat-title-slot { display: contents; }
6416
6667
  #chat-title-primary {
6417
- display: block;
6668
+ order: 2;
6669
+ flex: 1 1 0;
6418
6670
  min-width: 0;
6419
6671
  }
6672
+ #chat-title-origin,
6673
+ #chat-title-relay-events,
6674
+ #chat-title-relay-schedules,
6675
+ #chat-title-cwd,
6676
+ #chat-title-session-usage {
6677
+ order: 5;
6678
+ flex: 0 1 auto;
6679
+ max-width: 100%;
6680
+ margin-top: 4px;
6681
+ }
6682
+ #chat-title-relay-events,
6683
+ #chat-title-relay-schedules { flex-shrink: 0; }
6684
+ #chat-title-relay-events .relay-watch-chip,
6685
+ #chat-title-relay-events .conv-watched-badge,
6686
+ #chat-title-relay-schedules .relay-watch-chip { white-space: nowrap; }
6687
+ #chat-title-primary { display: block; }
6420
6688
  #chat-title-cwd {
6421
6689
  display: block;
6422
- margin-top: 1px;
6423
6690
  font-size: clamp(0.62rem, 2vw, 0.7rem);
6424
6691
  line-height: 1.2;
6425
6692
  min-width: 0;
@@ -6429,7 +6696,6 @@
6429
6696
  }
6430
6697
  #chat-title-session-usage {
6431
6698
  display: block;
6432
- margin-top: 1px;
6433
6699
  font-size: clamp(0.62rem, 2vw, 0.7rem);
6434
6700
  line-height: 1.2;
6435
6701
  min-width: 0;
@@ -6437,10 +6703,7 @@
6437
6703
  overflow: hidden;
6438
6704
  text-overflow: ellipsis;
6439
6705
  }
6440
- #chat-title-origin {
6441
- margin-top: 1px;
6442
- font-size: clamp(0.62rem, 2vw, 0.7rem);
6443
- }
6706
+ #chat-title-origin { font-size: clamp(0.62rem, 2vw, 0.7rem); }
6444
6707
  #chat-header-actions {
6445
6708
  margin-left: auto;
6446
6709
  }
@@ -6449,6 +6712,7 @@
6449
6712
  flex: 0 0 auto;
6450
6713
  }
6451
6714
  #chat-title-editor {
6715
+ order: 2;
6452
6716
  flex: 1 0 100%;
6453
6717
  width: 100%;
6454
6718
  flex-wrap: wrap;
@@ -6457,9 +6721,6 @@
6457
6721
  body.chat-title-editing #chat-header-actions {
6458
6722
  align-self: flex-start;
6459
6723
  }
6460
- body.chat-title-editing #chat-title-wrap {
6461
- z-index: 40;
6462
- }
6463
6724
  body.chat-title-editing #chat-title-editor {
6464
6725
  position: fixed;
6465
6726
  left: 10px;
@@ -6504,7 +6765,6 @@
6504
6765
  color: var(--relay-tone-tunnelled);
6505
6766
  text-shadow: 0 0 8px rgba(210, 153, 34, 0.35);
6506
6767
  }
6507
- #chat-title-wrap { order: 2; }
6508
6768
  #chat-header-actions { order: 3; }
6509
6769
  #chat-title { font-size: clamp(0.92rem, 3vw, 0.98rem); }
6510
6770
  #chat-title-input,
@@ -6777,13 +7037,20 @@
6777
7037
  flex: 0 0 auto;
6778
7038
  min-width: 40px;
6779
7039
  }
6780
- /* About OAR: the same, with Refresh beside the close button. */
6781
- #summary-modal[data-kind="about"] .summary-header-actions {
7040
+ /* About OAR, Plan usage and Settings: the same, with Refresh (where
7041
+ there is one) beside the close button, and the title cut with an
7042
+ ellipsis if it must be. */
7043
+ #summary-modal[data-kind="about"] .summary-header-actions,
7044
+ #summary-modal[data-kind="usage"] .summary-header-actions,
7045
+ #settings-modal .summary-header-actions {
6782
7046
  width: auto;
6783
7047
  flex: 0 0 auto;
6784
7048
  }
6785
7049
  #summary-modal[data-kind="about"] .summary-header-actions .summary-btn,
6786
- #summary-modal[data-kind="about"] .summary-header-actions .summary-close {
7050
+ #summary-modal[data-kind="about"] .summary-header-actions .summary-close,
7051
+ #summary-modal[data-kind="usage"] .summary-header-actions .summary-btn,
7052
+ #summary-modal[data-kind="usage"] .summary-header-actions .summary-close,
7053
+ #settings-modal .summary-header-actions .summary-close {
6787
7054
  flex: 0 0 auto;
6788
7055
  min-width: 40px;
6789
7056
  }
@@ -7610,6 +7877,16 @@
7610
7877
  tabindex="-1"
7611
7878
  data-settings-provider-tab="cursor"
7612
7879
  >Cursor</button>
7880
+ <button
7881
+ id="settings-provider-tab-deepseek"
7882
+ class="settings-tab settings-subtab"
7883
+ type="button"
7884
+ role="tab"
7885
+ aria-selected="false"
7886
+ aria-controls="settings-provider-panel-deepseek"
7887
+ tabindex="-1"
7888
+ data-settings-provider-tab="deepseek"
7889
+ >DeepSeek</button>
7613
7890
  </div>
7614
7891
  <div
7615
7892
  id="settings-provider-panel-copilot"
@@ -8047,6 +8324,104 @@
8047
8324
  <div class="settings-help">Unlocks the live percentage bars on Check Usage: "Plan usage (total)", "Auto + Composer" and "API". Detected automatically from this machine's Cursor IDE login when one exists; paste the <code>WorkosCursorSessionToken</code> cookie from a logged-in cursor.com tab (DevTools → Application → Cookies) only for headless relay hosts or to use a different account. Headless hosts can also set <code>CURSOR_SESSION_TOKEN</code> in the relay environment. Tokens stay on the relay.</div>
8048
8325
  </div>
8049
8326
  </div>
8327
+ <div
8328
+ id="settings-provider-panel-deepseek"
8329
+ class="settings-panel"
8330
+ role="tabpanel"
8331
+ tabindex="0"
8332
+ aria-labelledby="settings-provider-tab-deepseek"
8333
+ data-settings-provider-panel="deepseek"
8334
+ hidden
8335
+ >
8336
+ <div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
8337
+ <label for="deepseek-api-key-input">DeepSeek</label>
8338
+ <div class="settings-row" style="width: 100%; margin-bottom: 8px;">
8339
+ <label for="deepseek-enabled-toggle">Enable DeepSeek for New Chat model selection</label>
8340
+ <input id="deepseek-enabled-toggle" type="checkbox" onchange="toggleDeepSeekProvider(this.checked)" disabled>
8341
+ </div>
8342
+ <input
8343
+ id="deepseek-api-key-input"
8344
+ type="password"
8345
+ autocomplete="off"
8346
+ placeholder="DeepSeek API key…"
8347
+ aria-label="DeepSeek API key"
8348
+ >
8349
+ <label for="deepseek-model-select" style="margin-top: 8px;">Default model</label>
8350
+ <select id="deepseek-model-select" aria-label="DeepSeek default model">
8351
+ <option value="deepseek-flash">deepseek-flash</option>
8352
+ <option value="deepseek-v4-pro">deepseek-v4-pro</option>
8353
+ </select>
8354
+ <label for="deepseek-attribution-select" style="margin-top: 8px;">Commit attribution</label>
8355
+ <select id="deepseek-attribution-select" aria-label="DeepSeek commit attribution">
8356
+ <option value="oar">OAR: Co-authored-by: Open Agent Relay (DeepSeek …)</option>
8357
+ <option value="off">Off: no attribution</option>
8358
+ </select>
8359
+ <label for="deepseek-monthly-budget-input" style="margin-top: 8px;">Monthly budget (optional)</label>
8360
+ <input
8361
+ id="deepseek-monthly-budget-input"
8362
+ type="number"
8363
+ inputmode="decimal"
8364
+ min="0"
8365
+ step="0.01"
8366
+ autocomplete="off"
8367
+ placeholder="No budget bar"
8368
+ aria-label="DeepSeek monthly budget"
8369
+ >
8370
+ <label for="deepseek-monthly-budget-currency-select" style="margin-top: 8px;">Budget currency</label>
8371
+ <select id="deepseek-monthly-budget-currency-select" aria-label="DeepSeek monthly budget currency">
8372
+ <option value="USD">USD ($)</option>
8373
+ <option value="CNY">CNY (¥)</option>
8374
+ <option value="">The balance's currency (may change)</option>
8375
+ </select>
8376
+ <div class="settings-help">The usage card shows this relay's estimated DeepSeek spend of the calendar month (UTC) against the budget, in the budget's currency, amber near the budget and red over it. Leave the amount empty for no budget bar.</div>
8377
+ <label for="deepseek-starting-balance-input" style="margin-top: 8px;">Starting balance (optional)</label>
8378
+ <input
8379
+ id="deepseek-starting-balance-input"
8380
+ type="number"
8381
+ inputmode="decimal"
8382
+ min="0"
8383
+ step="0.01"
8384
+ autocomplete="off"
8385
+ placeholder="The balance after the last top-up"
8386
+ aria-label="DeepSeek starting balance"
8387
+ aria-describedby="deepseek-starting-balance-help"
8388
+ >
8389
+ <div id="deepseek-starting-balance-help" class="settings-help">The usage card's balance bar measures what is left against this amount, in the balance's currency, for example your first top-up. Leave it empty to measure from the balance after the last top-up this relay has seen (before it has seen one, the highest balance it has read).</div>
8390
+ <div id="deepseek-starting-balance-status" class="settings-help" hidden></div>
8391
+ <details id="deepseek-advanced" style="margin-top: 8px;">
8392
+ <summary>Advanced</summary>
8393
+ <label for="deepseek-base-url-input" style="margin-top: 8px;">Base URL</label>
8394
+ <input
8395
+ id="deepseek-base-url-input"
8396
+ type="url"
8397
+ autocomplete="off"
8398
+ placeholder="https://api.deepseek.com"
8399
+ aria-label="DeepSeek base URL"
8400
+ >
8401
+ <div class="settings-help">Leave empty for DeepSeek's own API. Another address must use https (http only on this machine, for a local proxy); the key is sent there and nowhere else.</div>
8402
+ </details>
8403
+ <div id="deepseek-settings-status" class="settings-help settings-provider-status">Loading DeepSeek settings…</div>
8404
+ <div style="display: flex; gap: 8px; margin-top: 8px;">
8405
+ <button id="deepseek-save-btn" type="button" class="summary-btn" onclick="saveDeepSeekSettings()">Save DeepSeek settings</button>
8406
+ <button id="deepseek-remove-btn" type="button" class="summary-btn" onclick="removeDeepSeekSettings()">Remove saved key</button>
8407
+ </div>
8408
+ <div class="settings-help">DeepSeek chats run Claude Code on the relay host against DeepSeek's Anthropic-compatible API, with all four modes, tools and question cards. The key is stored on the relay host and never sent to browsers; saving it lists DeepSeek's models. Turns are billed to the DeepSeek account the key belongs to.</div>
8409
+ <div id="deepseek-usage-import" class="deepseek-usage-import" role="group" aria-labelledby="deepseek-usage-import-title">
8410
+ <label id="deepseek-usage-import-title">Import usage export</label>
8411
+ <div class="settings-help">DeepSeek's usage page exports two files for a period, amount and cost. Imported here, their figures for the saved key replace the estimate on the usage card for the days they cover; the other days stay estimated.</div>
8412
+ <div class="deepseek-usage-import-files">
8413
+ <label for="deepseek-usage-amount-input" class="settings-help">Amount file</label>
8414
+ <input id="deepseek-usage-amount-input" type="file" accept=".csv,text/csv" aria-label="DeepSeek usage export: amount file">
8415
+ <label for="deepseek-usage-cost-input" class="settings-help">Cost file</label>
8416
+ <input id="deepseek-usage-cost-input" type="file" accept=".csv,text/csv" aria-label="DeepSeek usage export: cost file">
8417
+ </div>
8418
+ <div>
8419
+ <button id="deepseek-usage-import-btn" type="button" class="summary-btn" onclick="importDeepSeekUsageExport()">Import usage export</button>
8420
+ </div>
8421
+ <div id="deepseek-usage-import-status" class="settings-help settings-provider-status deepseek-usage-import-status" role="status" aria-live="polite"></div>
8422
+ </div>
8423
+ </div>
8424
+ </div>
8050
8425
  </div>
8051
8426
  <div
8052
8427
  id="settings-panel-relays"
@@ -18,7 +18,9 @@ import { compareGroupMembers } from '../public/app/conversation-group-order.mjs'
18
18
  //
19
19
  // A group can have a hand order (migration 0015): once the owner drags a row
20
20
  // into place, every member after the head gets a `group_position` (10, 20,
21
- // 30, ...) and keeps it; later arrivals join at the end. A group nobody
21
+ // 30, ...) and keeps it; a later arrival (a session created in the group, a
22
+ // row the menu moves in) goes first, right under the head, and the next
23
+ // drag renumbers from 10 again. A group nobody
22
24
  // reordered keeps NULL positions and the activity order. Without the column
23
25
  // the hand order is off and moves only change the membership.
24
26
  //
@@ -141,8 +143,8 @@ export function createConversationGroupRepository(db) {
141
143
  WHERE group_id = ? AND status != 'deleted'
142
144
  ORDER BY updated_at DESC
143
145
  `);
144
- const maxPosition = hasPosition
145
- ? db.prepare(`SELECT MAX(group_position) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`)
146
+ const minPosition = hasPosition
147
+ ? db.prepare(`SELECT MIN(group_position) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`)
146
148
  : null;
147
149
  const countMembers = db.prepare(`SELECT COUNT(*) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`);
148
150
  // Groups with a live member, the one with the latest activity first, then
@@ -188,11 +190,16 @@ export function createConversationGroupRepository(db) {
188
190
 
189
191
  const positionValue = (row) => (row?.group_position == null ? null : Number(row.group_position));
190
192
 
191
- /** Where a row joining `groupId` goes: after the last placed member, or NULL when nobody ordered the group. */
193
+ /**
194
+ * Where a row joining `groupId` goes: before the first placed member (the
195
+ * owner sees a new session at the top of its group, under the head), or
196
+ * NULL when nobody ordered the group, where newest-first puts it on top
197
+ * anyway. Positions may go to zero and below; the next drag renumbers.
198
+ */
192
199
  function joiningPosition(groupId) {
193
200
  if (!hasPosition) return null;
194
- const max = maxPosition.get(toText(groupId))?.n;
195
- return max == null ? null : Number(max) + POSITION_STEP;
201
+ const min = minPosition.get(toText(groupId))?.n;
202
+ return min == null ? null : Number(min) - POSITION_STEP;
196
203
  }
197
204
 
198
205
  const liveConversation = (id) => {
@@ -129,7 +129,8 @@ export function registerConversationGroupRoutes(app, deps) {
129
129
  // a conversation into a group. `groupId: null` takes it out (as DELETE).
130
130
  // With `beforeConversationId` in the body (null: to the end) it is a drag:
131
131
  // the row lands before that one and the group gets a hand order; without
132
- // it the row joins at the end of a hand-ordered group, as the menu does.
132
+ // it the row joins a hand-ordered group first, under the head, as the menu
133
+ // does (and the next drag renumbers).
133
134
  app.put('/api/conversation/:id/group', auth, (req, res) => {
134
135
  if (refuseRemote(req, res)) return;
135
136
  if (!conversationGroups?.enabled) return unavailable(res);