ompchamber 3.1.1 → 3.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 (151) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +306 -298
  3. package/dist/client/static/js/index-bsxxt89j.css +1 -0
  4. package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
  5. package/package.json +4 -3
  6. package/public/sw.js +54 -5
  7. package/src/cli/lib/output.js +15 -8
  8. package/src/client/components/common/Modal.tsx +14 -4
  9. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  10. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  11. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  12. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  13. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  14. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  15. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
  16. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
  17. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
  18. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
  19. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  20. package/src/client/components/settings/Modal.tsx +45 -23
  21. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  22. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  23. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  24. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  25. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  26. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  27. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  28. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  29. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  30. package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
  31. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  32. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  33. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  34. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  35. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  36. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  37. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  38. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  39. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  40. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  41. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  42. package/src/client/components/settings/master-detail/index.tsx +68 -0
  43. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  45. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +19 -12
  46. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  47. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  55. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  56. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  57. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  58. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  59. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  60. package/src/client/components/workspace/editor/index.tsx +15 -7
  61. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  62. package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
  63. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
  64. package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
  65. package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
  66. package/src/client/data/theme/terminal.ts +25 -8
  67. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  68. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  69. package/src/client/hooks/chat/subagent/index.ts +37 -11
  70. package/src/client/hooks/editor/font.ts +53 -0
  71. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  72. package/src/client/hooks/settings/master-detail.ts +33 -0
  73. package/src/client/hooks/ui/clipboard.ts +17 -0
  74. package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
  75. package/src/client/hooks/ui/keyboard-inset.ts +113 -0
  76. package/src/client/hooks/ui/text-overflow.test.ts +152 -0
  77. package/src/client/hooks/ui/text-overflow.ts +78 -0
  78. package/src/client/hooks/ui/touch-device.ts +46 -0
  79. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  80. package/src/client/main.tsx +1 -0
  81. package/src/server/index.ts +4 -3
  82. package/src/server/lib/assets/conditional.server.test.ts +74 -0
  83. package/src/server/lib/assets/conditional.server.ts +54 -0
  84. package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
  85. package/src/server/lib/assets/dev-assets.server.ts +121 -0
  86. package/src/server/lib/assets/fonts.server.ts +9 -4
  87. package/src/server/lib/bundler/build-errors.server.ts +136 -0
  88. package/src/server/lib/bundler/build-errors.test.ts +103 -0
  89. package/src/server/lib/bundler/dependencies.test.ts +134 -0
  90. package/src/server/lib/db/client.ts +5 -1
  91. package/src/server/lib/fs/git-diff.ts +34 -10
  92. package/src/server/lib/fs/git-repos.ts +81 -0
  93. package/src/server/lib/lifecycle/flock.ts +199 -0
  94. package/src/server/lib/lifecycle/identity.ts +5 -1
  95. package/src/server/lib/lifecycle/listener.ts +29 -0
  96. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  97. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  98. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  99. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  100. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  101. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  102. package/src/server/lib/observer-ws.ts +150 -0
  103. package/src/server/lib/omp/rpc/constants.ts +9 -0
  104. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  105. package/src/server/lib/omp/session/file-scan.ts +7 -1
  106. package/src/server/lib/omp/session/files.ts +95 -96
  107. package/src/server/lib/omp/session/locator.ts +18 -8
  108. package/src/server/lib/omp/session/messages.ts +7 -7
  109. package/src/server/lib/omp/session/scan.ts +108 -0
  110. package/src/server/lib/omp/session/stats.ts +50 -50
  111. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  112. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  113. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  114. package/src/server/lib/terminal/runtime.server.ts +3 -0
  115. package/src/server/lib/terminal/session.server.ts +30 -3
  116. package/src/server/plugins/shell.server.ts +52 -13
  117. package/src/server/plugins/ssr.ts +43 -7
  118. package/src/server/plugins/static.ts +51 -18
  119. package/src/server/routes/agent/command.test.ts +69 -0
  120. package/src/server/routes/agent/command.ts +14 -0
  121. package/src/server/routes/agent/ws.test.ts +159 -0
  122. package/src/server/routes/agent/ws.ts +22 -97
  123. package/src/server/routes/btw/ws.ts +22 -95
  124. package/src/server/routes/fs/git.ts +61 -70
  125. package/src/server/routes/omp/session-stats.ts +1 -1
  126. package/src/server/routes/telemetry/tokens.ts +6 -4
  127. package/src/server/routes/terminal/run.ts +4 -8
  128. package/src/server/routes/terminal/ws.ts +24 -2
  129. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  130. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  131. package/src/shared/lib/code/editor/typography.test.ts +93 -0
  132. package/src/shared/lib/code/editor/typography.ts +86 -0
  133. package/src/shared/lib/code/language.ts +146 -0
  134. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  135. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  136. package/src/shared/lib/fonts/NOTICE.md +44 -0
  137. package/src/shared/lib/fonts/nerd-symbols.css +55 -0
  138. package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
  139. package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
  140. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  141. package/src/shared/lib/fs/diff-parser.ts +67 -42
  142. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  143. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  144. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  145. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  146. package/src/shared/lib/settings/diff.test.ts +1 -1
  147. package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
  148. package/src/shared/lib/workspace/terminal/keys.ts +225 -0
  149. package/src/shared/types/omp/subagent.ts +6 -0
  150. package/src/shared/types/settings/state.ts +7 -16
  151. package/dist/client/static/js/index-5yfre1kw.css +0 -1
@@ -31,7 +31,7 @@ export const McpSidebarList: FunctionComponent<McpSidebarListProps> = ({
31
31
  const activeProjectName = projects.find((proj) => proj.path === selectedProject)?.name ?? 'Global (all projects)';
32
32
 
33
33
  return (
34
- <div className="w-56 sm:w-64 border-r border-ink/10 h-full flex flex-col bg-paper/50 flex-shrink-0 select-none">
34
+ <div className="w-full md:w-64 md:border-r border-ink/10 h-full flex flex-col bg-paper/50 flex-shrink-0 select-none">
35
35
  <ProjectSelectorDropdown
36
36
  selectedProject={selectedProject}
37
37
  onChangeProject={onChangeProject}
@@ -39,7 +39,7 @@ export function ProjectSidebarList({
39
39
  onAddProject,
40
40
  }: ProjectSidebarListProps) {
41
41
  return (
42
- <div className="w-56 sm:w-64 border-r border-ink/10 h-full flex flex-col bg-paper/50 flex-shrink-0">
42
+ <div className="w-full md:w-64 md:border-r border-ink/10 h-full flex flex-col bg-paper/50 flex-shrink-0">
43
43
  {/* List Header */}
44
44
  <div className="px-3.5 py-3 border-b border-ink/10 flex items-center justify-between">
45
45
  <span className="text-xs font-semibold text-ink">
@@ -31,8 +31,13 @@ export function ProviderAuthSection({
31
31
  Authentication
32
32
  </div>
33
33
 
34
- <div className="flex items-center justify-between gap-4">
35
- <div className="flex items-center gap-2">
34
+ {/* Wraps instead of overflowing: this row is a status group plus three
35
+ action buttons, which needs ~330px, while the detail pane is 206px
36
+ at a 768px window (the modal's own two-pane split). Without the wrap
37
+ the buttons ran past the panel and were clipped by the modal's
38
+ `overflow-hidden`. */}
39
+ <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
40
+ <div className="flex items-center gap-2 min-w-0">
36
41
  {isDisabled ? (
37
42
  <>
38
43
  <Ban size={15} className="text-ink/50 flex-shrink-0" strokeWidth={2.4} />
@@ -68,7 +73,7 @@ export function ProviderAuthSection({
68
73
  </div>
69
74
  </div>
70
75
 
71
- <div className="flex items-center gap-2">
76
+ <div className="flex flex-wrap items-center gap-2">
72
77
  <button
73
78
  type="button"
74
79
  onClick={onToggleDisabled}
@@ -57,7 +57,7 @@ export function DeleteProviderModal({
57
57
  aria-label="Confirm provider deletion"
58
58
  >
59
59
  <div
60
- className="bg-paper border border-ink/20 rounded-xl shadow-2xl w-[min(94vw,460px)] overflow-hidden text-ink"
60
+ className="bg-paper border border-ink/20 rounded-xl shadow-2xl w-[min(94vw,460px)] max-h-full overflow-y-auto scrollbar-overlay-container scrollbar-overlay-static text-ink"
61
61
  onClick={(e) => e.stopPropagation()}
62
62
  >
63
63
  <div className="flex items-center gap-3 px-5 py-4 border-b border-ink/10">
@@ -42,12 +42,14 @@ export function ProviderModelsList({
42
42
  return (
43
43
  <div className="space-y-3.5">
44
44
  {/* 1. Header with count & hide all / show all */}
45
- <div className="flex items-center justify-between gap-4">
45
+ <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
46
46
  <div className="text-xs font-semibold text-ink">
47
47
  Available Models <span className="font-normal text-ink/60">({models.length})</span>
48
48
  </div>
49
49
 
50
- <div className="flex items-center gap-2">
50
+ {/* Wraps: four action chips need ~330px, which the detail pane does not
51
+ have in a two-pane modal at a 768-900px window. */}
52
+ <div className="flex flex-wrap items-center gap-2">
51
53
  <button
52
54
  type="button"
53
55
  onClick={onFetchModels}
@@ -133,8 +135,11 @@ export function ProviderModelsList({
133
135
  </span>
134
136
  </div>
135
137
 
136
- {/* Right: Price & Context Badge & Action Icons */}
137
- <div className="flex items-center gap-2 flex-shrink-0">
138
+ {/* Right: Price & Context Badge & Action Icons. Wraps rather than
139
+ running past the pane: a long price plus a context badge plus
140
+ three icons exceeds the detail pane's width in a two-pane
141
+ modal on a ~768px window. */}
142
+ <div className="flex flex-wrap items-center justify-end gap-2">
138
143
  {/* Pricing Badge (per 1M tokens) */}
139
144
  {(model.priceInput !== undefined || model.priceOutput !== undefined) && (
140
145
  <span
@@ -35,7 +35,7 @@ export function ProviderSidebarList({
35
35
  useOnClickOutside(dropdownRef, () => setIsProjectDropdownOpen(false));
36
36
 
37
37
  return (
38
- <div className="w-56 sm:w-64 border-r border-ink/10 h-full flex flex-col bg-paper/50 flex-shrink-0">
38
+ <div className="w-full md:w-64 md:border-r border-ink/10 h-full flex flex-col bg-paper/50 flex-shrink-0">
39
39
  {/* 1. Project Selector Dropdown */}
40
40
  <div className="p-3 border-b border-ink/10 relative" ref={dropdownRef}>
41
41
  <button
@@ -50,7 +50,7 @@ export function AddSourceModal({ isOpen, onClose, onAddSource }: AddSourceModalP
50
50
  onClick={onClose}
51
51
  >
52
52
  <div
53
- className="bg-paper border border-ink/20 rounded-xl max-w-md w-full p-6 shadow-2xl space-y-4 text-ink animate-in fade-in zoom-in-95 duration-150"
53
+ className="bg-paper border border-ink/20 rounded-xl max-w-md w-full p-6 shadow-2xl space-y-4 text-ink animate-in fade-in zoom-in-95 duration-150 max-h-full overflow-y-auto scrollbar-overlay-container scrollbar-overlay-static"
54
54
  onClick={(e) => e.stopPropagation()}
55
55
  >
56
56
  <div className="flex items-center justify-between pb-3 border-b border-ink/10">
@@ -49,7 +49,7 @@ export function SkillSidebarList({
49
49
  const totalCount = skills.length;
50
50
 
51
51
  return (
52
- <div className="w-56 sm:w-64 border-r border-ink/10 h-full flex flex-col bg-paper/50 flex-shrink-0">
52
+ <div className="w-full md:w-64 md:border-r border-ink/10 h-full flex flex-col bg-paper/50 flex-shrink-0">
53
53
  {/* Project Selector Dropdown */}
54
54
  <ProjectSelectorDropdown
55
55
  variant="compact"
@@ -24,7 +24,7 @@ export function UsageSidebarList({
24
24
  const providers = buildProviders(report);
25
25
 
26
26
  return (
27
- <div className="w-56 sm:w-64 border-r border-ink/10 h-full flex flex-col bg-paper/50 flex-shrink-0 select-none">
27
+ <div className="w-full md:w-64 md:border-r border-ink/10 h-full flex flex-col bg-paper/50 flex-shrink-0 select-none">
28
28
  <div className="px-3.5 py-2.5 border-b border-ink/10 flex items-center justify-between">
29
29
  <span className="text-xs font-semibold text-ink">
30
30
  {providers.length} provider{providers.length === 1 ? '' : 's'} with keys
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import type { ComponentChildren } from 'preact';
7
+ import { ChevronLeft } from 'lucide-preact';
8
+ import type { SettingsPane } from '@/client/hooks/settings/master-detail';
9
+
10
+ interface SettingsMasterDetailProps {
11
+ /** Which pane a phone is showing; desktop shows both. */
12
+ pane: SettingsPane;
13
+ onBack: () => void;
14
+ /** The list pane (a settings sidebar). Owns its own `w-full md:w-64` width. */
15
+ list: ComponentChildren;
16
+ detail: ComponentChildren;
17
+ /** Name of the list, shown on the phone's back bar. */
18
+ listLabel: string;
19
+ /** Extra classes for the root (e.g. a category-wide `text-xs`). */
20
+ className?: string;
21
+ }
22
+
23
+ /**
24
+ * Master-detail shell for the settings categories that are two panes on a
25
+ * desktop (providers, projects, agents, commands, MCP, skills, usage).
26
+ *
27
+ * Both panes stay mounted at every width, so a pane's state — a half-typed
28
+ * form, a scroll position — survives the drill and the back. Only the phone
29
+ * needs the extra bar: on `md` the two panes sit side by side exactly as
30
+ * before and the bar is not rendered at all.
31
+ */
32
+ export function SettingsMasterDetail({
33
+ pane,
34
+ onBack,
35
+ list,
36
+ detail,
37
+ listLabel,
38
+ className = '',
39
+ }: SettingsMasterDetailProps) {
40
+ return (
41
+ <div className={`flex-1 flex flex-col md:flex-row h-full w-full min-w-0 overflow-hidden bg-paper text-ink ${className}`}>
42
+ {/* `min-w-0` on both panes: a flex item's default `min-width: auto` is its
43
+ content's min-content width, so the detail pane refused to shrink below
44
+ its widest row (a provider's name + action strip) and ran 207px past the
45
+ modal at a 768-900px window — clipped, since the modal is
46
+ `overflow-hidden`. The list pane carries it for the same reason. */}
47
+ <div className={`${pane === 'list' ? 'flex' : 'hidden'} md:flex flex-1 md:flex-none md:w-auto min-h-0 min-w-0 flex-col`}>
48
+ {list}
49
+ </div>
50
+
51
+ <div className={`${pane === 'detail' ? 'flex' : 'hidden'} md:flex flex-1 min-h-0 min-w-0 flex-col`}>
52
+ <div className="md:hidden flex items-center px-2 py-1 border-b border-ink/10 bg-canvas flex-shrink-0">
53
+ <button
54
+ type="button"
55
+ onClick={onBack}
56
+ aria-label={`Back to ${listLabel}`}
57
+ className="flex items-center gap-1 -ml-0.5 pl-0.5 pr-2 py-1 rounded-lg text-ink hover:bg-ink/5 active:bg-ink/10 transition-colors cursor-pointer"
58
+ >
59
+ <ChevronLeft size={18} />
60
+ <span className="text-xs font-semibold truncate">{listLabel}</span>
61
+ </button>
62
+ </div>
63
+
64
+ {detail}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
@@ -9,7 +9,8 @@ import { useSessionState } from '@/client/hooks/workspace/session-state';
9
9
  import { useRepoList, useRepoScope } from '@/client/hooks/workspace/repo-scope';
10
10
  import { useSearchStream } from '@/client/hooks/workspace/search-stream';
11
11
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
12
- import { getLanguageFromPath, highlightCode } from '@/shared/lib/code/syntax-highlight';
12
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
13
+ import { highlightCode } from '@/shared/lib/code/syntax-highlight';
13
14
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
14
15
 
15
16
  export function SearchPanel({ className = '', enabled = true, rootPath }: { className?: string, enabled?: boolean, rootPath?: string }) {
@@ -18,6 +18,8 @@ interface SubagentViewProps {
18
18
  onBack: () => void;
19
19
  provider?: string;
20
20
  providerNames?: Record<string, string>;
21
+ /** Mobile renders the chat's compact footer variant (three-dot menu). */
22
+ isMobile?: boolean;
21
23
  className?: string;
22
24
  }
23
25
 
@@ -30,7 +32,7 @@ const STATUS_LABEL: Record<SubagentInfo['status'], string> = {
30
32
 
31
33
  /** Read-only subagent transcript: compact banner, live-growing message list,
32
34
  * and a notice row where the main timeline's composer would be. */
33
- export function SubagentView({ sessionId, subagent, onBack, provider, providerNames, className = '' }: SubagentViewProps) {
35
+ export function SubagentView({ sessionId, subagent, onBack, provider, providerNames, isMobile = false, className = '' }: SubagentViewProps) {
34
36
  const { messages, isLoading, status, isActive } = useSubagentTranscript(sessionId, subagent, onBack);
35
37
  const modelNames = useModelNames();
36
38
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -133,6 +135,7 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
133
135
  msg={msg}
134
136
  isStreaming={isLoading}
135
137
  isPrevAssistant={isPrevAssistant}
138
+ userActions={false}
136
139
  className={isNoticeRow(msg) ? 'mt-3 mb-1' : isPrevAssistant ? 'mt-1' : 'mt-3'}
137
140
  />
138
141
  {footer && (
@@ -142,7 +145,10 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
142
145
  providerNames={providerNames}
143
146
  modelName={modelName}
144
147
  modelNames={modelNames}
148
+ thinkingLevel={progress?.resolvedThinkingLevel}
145
149
  durationMs={footer.durationMs}
150
+ isMobile={isMobile}
151
+ showActions={false}
146
152
  />
147
153
  )}
148
154
  </Fragment>
@@ -1,7 +1,9 @@
1
- import { useMemo, useState } from 'preact/hooks';
1
+ import { useMemo, useRef, useState } from 'preact/hooks';
2
2
  import { AlertCircle, Bell, Bot, CheckCircle2, ChevronDown, Clock, Info, Layers } from 'lucide-preact';
3
- import { highlightCode, isCodeLike } from '@/shared/lib/code/syntax-highlight';
3
+ import { isCodeLike } from '@/shared/lib/code/language';
4
+ import { highlightCode } from '@/shared/lib/code/syntax-highlight';
4
5
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
6
+ import { useIsTruncated } from '@/client/hooks/ui/text-overflow';
5
7
  import { parseTaskNotice } from '@/shared/lib/chat/task-result-parser';
6
8
  import { TaskResultContent } from '@/client/components/workspace/chat-timeline/TaskResultContent';
7
9
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
@@ -59,12 +61,17 @@ export function SystemNotice({ notice }: SystemNoticeProps) {
59
61
  }, [notice]);
60
62
 
61
63
  const rawTitle = taskNotice?.intro || reminderInfo?.title || genericInfo.firstLine || notice;
62
- const isTruncated = rawTitle.length > 100;
63
- const displayTitle = rawTitle.slice(0, 100) + (isTruncated ? '...' : '');
64
+ // The subtitle is clipped by CSS at the card's own width, so the decision to
65
+ // offer an expander is a MEASUREMENT, not a character count: 76 characters
66
+ // fit a desktop timeline and are cut on a phone card. A char threshold left
67
+ // those rows showing an ellipsis with nothing to click (the chevron was
68
+ // dropped with the button disabled). Measured on the exact node that carries
69
+ // `truncate`, so it cannot disagree with the ellipsis the user sees.
70
+ const subtitleRef = useRef<HTMLSpanElement>(null);
71
+ const subtitleClipped = useIsTruncated(subtitleRef, rawTitle);
64
72
 
65
- // If notice has only 1 line of content AND does not exceed the 100-character ellipsis limit,
66
- // disable expand since the entire text is already fully visible in the sub-header.
67
- // If it exceeds the ellipsis limit (>100 chars) or has multiple lines, keep expand active.
73
+ // Expandable when the subtitle is visually clipped, or when the body holds
74
+ // something the one-line header cannot show (more lines, a task-result card).
68
75
  const isExpandable = useMemo(() => {
69
76
  if (taskNotice) return true;
70
77
  const clean = notice
@@ -74,8 +81,8 @@ export function SystemNotice({ notice }: SystemNoticeProps) {
74
81
  .split(/\r?\n/)
75
82
  .map((l) => l.trim())
76
83
  .filter(Boolean);
77
- return lines.length > 1 || clean.length > 100 || rawTitle.length > 100;
78
- }, [taskNotice, notice, rawTitle]);
84
+ return lines.length > 1 || subtitleClipped;
85
+ }, [taskNotice, notice, subtitleClipped]);
79
86
 
80
87
  const noticeStyle = useMemo(() => {
81
88
  if (taskNotice) {
@@ -168,9 +175,9 @@ export function SystemNotice({ notice }: SystemNoticeProps) {
168
175
  </span>
169
176
  )}
170
177
  </span>
171
- {!isOpen && displayTitle && (
172
- <span className="truncate font-mono text-[10.5px] text-ink/45" title={rawTitle}>
173
- {displayTitle}
178
+ {!isOpen && rawTitle && (
179
+ <span ref={subtitleRef} className="truncate font-mono text-[10.5px] text-ink/45" title={rawTitle}>
180
+ {rawTitle}
174
181
  </span>
175
182
  )}
176
183
  </span>
@@ -3,7 +3,8 @@ import { AlignLeft, Code, Eye, FileCode, FileText, Layers, Terminal } from 'luci
3
3
  import type { ParsedTaskNotice } from '@/shared/lib/chat/task-result-parser';
4
4
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
5
5
  import { CopyButton } from '@/client/components/common/CopyButton';
6
- import { highlightCode, isCodeLike } from '@/shared/lib/code/syntax-highlight';
6
+ import { isCodeLike } from '@/shared/lib/code/language';
7
+ import { highlightCode } from '@/shared/lib/code/syntax-highlight';
7
8
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
8
9
 
9
10
  interface TaskResultContentProps {
@@ -211,6 +211,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
211
211
  onBack={handleSubagentBack}
212
212
  provider={sessionProvider}
213
213
  providerNames={providerNames}
214
+ isMobile={isMobile}
214
215
  />
215
216
  ) : (
216
217
  <TimelineBody
@@ -12,7 +12,8 @@
12
12
 
13
13
  import { useMemo } from 'preact/hooks';
14
14
  import type { HashlineSection, HashlineVerb } from '@/shared/lib/omp/session/hashline-patch';
15
- import { getLanguageFromPath, highlightLines } from '@/shared/lib/code/syntax-highlight';
15
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
16
+ import { highlightLines } from '@/shared/lib/code/syntax-highlight';
16
17
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
17
18
 
18
19
  /** A hashline `CUT` removes lines the model already saw, so it reads as the
@@ -1,7 +1,8 @@
1
1
  import { useMemo } from 'preact/hooks';
2
2
  import { ArrowRight, CheckCircle2, Clock, Wand2 } from 'lucide-preact';
3
3
  import type { ToolCallData } from '@/shared/types';
4
- import { getLanguageFromPath, highlightLines } from '@/shared/lib/code/syntax-highlight';
4
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
5
+ import { highlightLines } from '@/shared/lib/code/syntax-highlight';
5
6
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
6
7
 
7
8
  interface AstEditItem {
@@ -3,7 +3,8 @@ import { Check, Clock } from 'lucide-preact';
3
3
  import type { ToolCallData } from '@/shared/types';
4
4
  import { CopyButton } from '@/client/components/common/CopyButton';
5
5
  import { stripAnsiCodes } from '@/shared/lib/code/ansi';
6
- import { highlightCode, isCodeLike, tryParseJson } from '@/shared/lib/code/syntax-highlight';
6
+ import { isCodeLike } from '@/shared/lib/code/language';
7
+ import { highlightCode, tryParseJson } from '@/shared/lib/code/syntax-highlight';
7
8
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
8
9
  import { JsonCodeBlock } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock';
9
10
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
@@ -6,7 +6,8 @@ import { DiffView } from '@/client/components/workspace/chat-timeline/tool-rende
6
6
  import { HashlinePatch } from '@/client/components/workspace/chat-timeline/tool-renderers/hashline-patch';
7
7
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
8
8
  import { EnvelopeHeader } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/EnvelopeHeader';
9
- import { getLanguageFromPath, highlightCode } from '@/shared/lib/code/syntax-highlight';
9
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
10
+ import { highlightCode } from '@/shared/lib/code/syntax-highlight';
10
11
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
11
12
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
12
13
  import { outputMarkdown, readToolOutput } from '@/shared/lib/chat/tool-output';
@@ -1,7 +1,8 @@
1
1
  import { useEffect, useMemo, useState } from 'preact/hooks';
2
2
  import { FileCode, FileImage, FileText, Folder, FolderOpen, Info, Loader2 } from 'lucide-preact';
3
3
  import { CopyButton } from '@/client/components/common/CopyButton';
4
- import { getLanguageFromPath, highlightCode } from '@/shared/lib/code/syntax-highlight';
4
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
5
+ import { highlightCode } from '@/shared/lib/code/syntax-highlight';
5
6
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
6
7
  import { parseDirListing, parseNumberedCode } from '@/shared/lib/code/parser';
7
8
  import { getImageMimeType } from '@/shared/lib/fs/file-kind';
@@ -2,7 +2,8 @@ import { useMemo, useState } from 'preact/hooks';
2
2
  import { ChevronRight, FileSearch, FileText, FolderSearch } from 'lucide-preact';
3
3
  import type { ToolCallData } from '@/shared/types';
4
4
  import { CopyButton } from '@/client/components/common/CopyButton';
5
- import { getLanguageFromPath, highlightLines } from '@/shared/lib/code/syntax-highlight';
5
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
6
+ import { highlightLines } from '@/shared/lib/code/syntax-highlight';
6
7
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
7
8
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
8
9
 
@@ -1,5 +1,6 @@
1
1
  import { useMemo } from 'preact/hooks';
2
- import { getLanguageFromPath, highlightLines } from '@/shared/lib/code/syntax-highlight';
2
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
3
+ import { highlightLines } from '@/shared/lib/code/syntax-highlight';
3
4
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
4
5
 
5
6
  interface DiffLine {
@@ -6,9 +6,11 @@ import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
6
6
  interface SplitViewProps {
7
7
  rows: SplitDiffRow[];
8
8
  language: string;
9
+ /** Wrap long lines instead of scrolling horizontally. */
10
+ wordWrap: boolean;
9
11
  }
10
12
 
11
- export function SplitView({ rows, language }: SplitViewProps) {
13
+ export function SplitView({ rows, language, wordWrap }: SplitViewProps) {
12
14
  const syntaxReady = useSyntaxReady();
13
15
 
14
16
  const htmlByIndex = useMemo(() => {
@@ -47,7 +49,7 @@ export function SplitView({ rows, language }: SplitViewProps) {
47
49
  {/* The room under the last row belongs to the content, not the scroller:
48
50
  a scroll container's own bottom padding is not part of its scrollable
49
51
  overflow, so the padding has to sit inside it to have any effect. */}
50
- <div className="w-max min-w-full min-w-[700px] pb-4">
52
+ <div className={`pb-4 ${wordWrap ? 'w-full' : 'w-max min-w-full min-w-[700px]'}`}>
51
53
  <div className="grid grid-cols-2 sticky top-0 z-10 bg-canvas border-b border-ink/10 text-[11px] text-ink/70 font-sans select-none shadow-xs">
52
54
  <div className="px-3 py-1 font-medium border-r border-ink/10 flex items-center justify-between">
53
55
  <span>Original (HEAD / Base)</span>
@@ -57,7 +59,23 @@ export function SplitView({ rows, language }: SplitViewProps) {
57
59
  </div>
58
60
  </div>
59
61
 
60
- <table className="w-full border-collapse">
62
+ {/* Wrap needs `table-fixed`: auto layout sizes a column by its widest
63
+ cell's UNAMBIGUOUS minimum (`overflow-wrap: break-word` does not
64
+ lower it), so a long line would widen the table and scroll instead of
65
+ wrapping. Fixed layout takes its widths from the FIRST row — which
66
+ here is the `colSpan={4}` meta row, so every column came out equal
67
+ (measured 99px) and the line-number gutters grew to the width of a
68
+ code column. The colgroup states them instead: two 48px gutters and
69
+ the two content columns sharing the rest. */}
70
+ <table className={`border-collapse ${wordWrap ? 'table-fixed w-full' : 'w-full'}`}>
71
+ {wordWrap && (
72
+ <colgroup>
73
+ <col className="w-12" />
74
+ <col />
75
+ <col className="w-12" />
76
+ <col />
77
+ </colgroup>
78
+ )}
61
79
  <tbody>
62
80
  {rows.map((row, idx) => {
63
81
  if (row.isMeta) {
@@ -81,6 +99,8 @@ export function SplitView({ rows, language }: SplitViewProps) {
81
99
  {/* Left Line Number */}
82
100
  <td
83
101
  className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
102
+ wordWrap ? 'align-top' : ''
103
+ } ${
84
104
  isLeftDel
85
105
  ? 'bg-error/15 text-error font-semibold'
86
106
  : left
@@ -93,7 +113,9 @@ export function SplitView({ rows, language }: SplitViewProps) {
93
113
 
94
114
  {/* Left Content (Original) */}
95
115
  <td
96
- className={`min-w-[320px] py-0.5 px-2 whitespace-pre border-r border-ink/10 text-ink ${
116
+ className={`py-0.5 px-2 border-r border-ink/10 text-ink ${
117
+ wordWrap ? 'whitespace-pre-wrap break-words align-top' : 'min-w-[320px] whitespace-pre'
118
+ } ${
97
119
  isLeftDel
98
120
  ? 'bg-error/10'
99
121
  : left
@@ -121,6 +143,8 @@ export function SplitView({ rows, language }: SplitViewProps) {
121
143
  {/* Right Line Number */}
122
144
  <td
123
145
  className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
146
+ wordWrap ? 'align-top' : ''
147
+ } ${
124
148
  isRightAdd
125
149
  ? 'bg-success/15 text-success font-semibold'
126
150
  : right
@@ -133,7 +157,9 @@ export function SplitView({ rows, language }: SplitViewProps) {
133
157
 
134
158
  {/* Right Content (Modified) */}
135
159
  <td
136
- className={`min-w-[320px] py-0.5 px-2 whitespace-pre text-ink ${
160
+ className={`py-0.5 px-2 text-ink ${
161
+ wordWrap ? 'whitespace-pre-wrap break-words align-top' : 'min-w-[320px] whitespace-pre'
162
+ } ${
137
163
  isRightAdd
138
164
  ? 'bg-success/10'
139
165
  : right
@@ -1,14 +1,24 @@
1
- import { AlignJustify, Check, Code2, Columns, Copy, Minus, Plus, RefreshCw, Space, Undo2 } from 'lucide-preact';
1
+ import { AlignJustify, Check, Code2, Columns, Copy, Diff, FileCode, Minus, Plus, RefreshCw, Space, Undo2, WrapText } from 'lucide-preact';
2
2
  import { FileIcon } from '@/client/components/common/file-icon';
3
3
  import { getGitStatusInfo } from '@/shared/lib/fs/git-status';
4
4
 
5
5
  export type DiffViewMode = 'unified' | 'split';
6
6
 
7
+ /**
8
+ * How much of the file the diff shows. `full` is the default: the whole file
9
+ * with the changes marked inside it, which is what a reader wants when the
10
+ * hunks alone leave the edit without context. `diff` asks git for its own
11
+ * default context and shows only the changed regions.
12
+ */
13
+ export type DiffContentMode = 'full' | 'diff';
14
+
7
15
  interface DiffToolbarProps {
8
16
  filePath: string;
9
17
  status: string;
10
18
  isStaged: boolean;
11
19
  viewMode: DiffViewMode;
20
+ contentMode: DiffContentMode;
21
+ wordWrap: boolean;
12
22
  ignoreWhitespace: boolean;
13
23
  additions: number;
14
24
  deletions: number;
@@ -17,6 +27,8 @@ interface DiffToolbarProps {
17
27
  /** A stage/unstage/discard write is in flight; the toolbar locks its actions. */
18
28
  isBusy?: boolean;
19
29
  onSetViewMode: (mode: DiffViewMode) => void;
30
+ onSetContentMode: (mode: DiffContentMode) => void;
31
+ onToggleWordWrap: () => void;
20
32
  onToggleWhitespace: () => void;
21
33
  onStageUnstage: () => void;
22
34
  onDiscard: () => void;
@@ -33,6 +45,8 @@ export function DiffToolbar({
33
45
  status,
34
46
  isStaged,
35
47
  viewMode,
48
+ contentMode,
49
+ wordWrap,
36
50
  ignoreWhitespace,
37
51
  additions,
38
52
  deletions,
@@ -40,6 +54,8 @@ export function DiffToolbar({
40
54
  isCopied,
41
55
  isBusy = false,
42
56
  onSetViewMode,
57
+ onSetContentMode,
58
+ onToggleWordWrap,
43
59
  onToggleWhitespace,
44
60
  onStageUnstage,
45
61
  onDiscard,
@@ -73,11 +89,15 @@ export function DiffToolbar({
73
89
  </div>
74
90
 
75
91
  {/* Right: Controls & actions. `ml-auto` keeps them right-aligned where the
76
- identity block is hidden. This strip scrolls on its own when the panel
77
- is too narrow, so no action is ever clipped out of reach. Each segment
78
- sets its OWN mode — toggling meant clicking the already-active segment
79
- flipped the view to the other one. */}
80
- <div className="flex items-center space-x-0.5 @[440px]:space-x-1 text-ink/70 ml-auto min-w-0 overflow-x-auto no-scrollbar">
92
+ identity block is hidden. This strip WRAPS rather than scrolls: a
93
+ 320px panel cannot hold ten actions in one row, and the previous
94
+ `overflow-x-auto no-scrollbar` clipped the overflow invisibly —
95
+ measured 361px of content in a 296px box, with `Stage` entirely
96
+ outside it and no scrollbar to say so. Wrapping costs one extra row on
97
+ a narrow panel and keeps every action reachable. Each segment sets its
98
+ OWN mode — toggling meant clicking the already-active segment flipped
99
+ the view to the other one. */}
100
+ <div className="flex flex-wrap items-center justify-end gap-x-0.5 @[440px]:gap-x-1 gap-y-1 text-ink/70 ml-auto">
81
101
  {/* Change counts live here, not in the identity block: the editor tab
82
102
  carries the file name but never the counts, so this is the one piece
83
103
  of the header that must survive a narrow panel. */}
@@ -118,6 +138,57 @@ export function DiffToolbar({
118
138
  </button>
119
139
  </div>
120
140
 
141
+ {/* Content scope toggle (Full Code vs Diff Only). A separate group from
142
+ the view-mode toggle because the two are orthogonal: Unified/Split is
143
+ the shape of the rows, Full/Diff is how many of them there are. Each
144
+ segment SETS its mode — clicking the active one must not flip the
145
+ other, which is what a toggle would do. */}
146
+ <div className="flex items-center bg-canvas rounded border border-ink/15 p-0.5 mr-0.5 @[440px]:mr-1 flex-shrink-0">
147
+ <button
148
+ type="button"
149
+ onClick={() => onSetContentMode('full')}
150
+ className={`px-2 py-0.5 rounded text-[11px] font-medium flex items-center space-x-1 transition-colors cursor-pointer flex-shrink-0 ${
151
+ contentMode === 'full' ? 'bg-paper text-ink shadow-xs' : 'text-ink/60 hover:text-ink'
152
+ }`}
153
+ title="Full code (whole file, changes marked)"
154
+ aria-label="Full code (whole file, changes marked)"
155
+ aria-pressed={contentMode === 'full'}
156
+ >
157
+ <FileCode size={12} />
158
+ <span className="hidden @[420px]:inline">Full Code</span>
159
+ </button>
160
+ <button
161
+ type="button"
162
+ onClick={() => onSetContentMode('diff')}
163
+ className={`px-2 py-0.5 rounded text-[11px] font-medium flex items-center space-x-1 transition-colors cursor-pointer flex-shrink-0 ${
164
+ contentMode === 'diff' ? 'bg-paper text-ink shadow-xs' : 'text-ink/60 hover:text-ink'
165
+ }`}
166
+ title="Diff only (changed hunks)"
167
+ aria-label="Diff only (changed hunks)"
168
+ aria-pressed={contentMode === 'diff'}
169
+ >
170
+ <Diff size={12} />
171
+ <span className="hidden @[420px]:inline">Diff Only</span>
172
+ </button>
173
+ </div>
174
+
175
+ {/* Word wrap. On by default: a diff of a minified file or a long line
176
+ is unreadable when it runs off the panel, and horizontal scrolling
177
+ hides the change the reader opened the diff to see. Off keeps one
178
+ line = one row, which is what a column-aligned comparison needs. */}
179
+ <button
180
+ type="button"
181
+ onClick={onToggleWordWrap}
182
+ className={`p-1.5 rounded transition-colors cursor-pointer flex-shrink-0 ${
183
+ wordWrap ? 'bg-ink/10 text-ink' : 'text-ink/50 hover:text-ink hover:bg-ink/5'
184
+ }`}
185
+ title={wordWrap ? 'Word wrap on' : 'Word wrap off'}
186
+ aria-label={wordWrap ? 'Word wrap on' : 'Word wrap off'}
187
+ aria-pressed={wordWrap}
188
+ >
189
+ <WrapText size={13} />
190
+ </button>
191
+
121
192
  {/* Ignore whitespace toggle */}
122
193
  <button
123
194
  type="button"