History

Add inline review comment selection to mobile diffs

- render comment rows and line selection state in the native diff view - wire long-press/tap gestures to start and complete review comments - tighten shared diff utilities and add coverage

Julius Marminge committed 6 months ago

Commit eba28031 parent a49c2d115 files · +640 −37Browse files

Unified · Split

15 files
  1. apps/mobile/global.css +20 −10
  2. apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +9 −1
  3. apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +194 −0
  4. apps/mobile/src/features/debug/native-review-diff/nativeReviewDiffModule.ts +17 −1
  5. apps/mobile/src/features/review/reviewCommentSelection.test.ts +60 −0
  6. apps/mobile/src/features/review/reviewCommentSelection.ts +86 −3
  7. apps/mobile/src/features/threads/ThreadFeed.tsx +51 −12
  8. apps/web/src/components/ThreadTerminalDrawer.browser.tsx +76 −1
  9. apps/web/src/components/ThreadTerminalDrawer.tsx +33 −6
  10. apps/web/src/lib/contextWindow.test.ts +17 −0
  11. apps/web/src/lib/contextWindow.ts +1 −1
  12. apps/web/src/lib/lruCache.test.ts +9 −0
  13. apps/web/src/lib/lruCache.ts +4 −0
  14. apps/web/src/lib/threadSort.test.ts +48 −0
  15. apps/web/src/lib/threadSort.ts +15 −2
apps/mobile/global.css+20−10
@@ -62,19 +62,24 @@ · 61 unmodified lines
6262 --color-status-bar: #f2f2f7;
6363
6464 /* Markdown */
65− --color-md-body: #262626;
66− --color-md-strong: #171717;
65+ --color-md-body: #111111;
66+ --color-md-strong: #000000;
6767 --color-md-link: #2563eb;
6868 --color-md-blockquote-border: rgba(0, 0, 0, 0.08);
6969 --color-md-blockquote-bg: rgba(0, 0, 0, 0.02);
7070 --color-md-code-bg: rgba(0, 0, 0, 0.04);
7171 --color-md-code-text: #262626;
72− --color-md-user-code-bg: rgba(255, 255, 255, 0.55);
73− --color-md-user-code-text: #262626;
74− --color-md-user-fence-bg: rgba(0, 0, 0, 0.04);
75− --color-md-user-fence-text: #1e3a5f;
72+ --color-md-user-code-bg: rgba(255, 255, 255, 0.22);
73+ --color-md-user-code-text: #ffffff;
74+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.16);
75+ --color-md-user-fence-text: #ffffff;
7676 --color-md-hr: rgba(0, 0, 0, 0.08);
7777
78+ /* iMessage-style user bubble */
79+ --color-user-bubble: #007aff;
80+ --color-user-bubble-foreground: #ffffff;
81+ --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);
82+
7883 /* Drawer / modal backdrop */
7984 --color-backdrop: rgba(0, 0, 0, 0.22);
8085 --color-drawer: rgba(255, 255, 255, 0.99);
@@ -151,12 +156,17 @@ · 70 unmodified lines
151156 --color-md-blockquote-bg: rgba(255, 255, 255, 0.03);
152157 --color-md-code-bg: rgba(255, 255, 255, 0.06);
153158 --color-md-code-text: #e5e5e5;
154− --color-md-user-code-bg: rgba(255, 255, 255, 0.1);
155− --color-md-user-code-text: #e5e5e5;
156− --color-md-user-fence-bg: rgba(255, 255, 255, 0.08);
157− --color-md-user-fence-text: #e5e5e5;
159+ --color-md-user-code-bg: rgba(255, 255, 255, 0.18);
160+ --color-md-user-code-text: #ffffff;
161+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.28);
162+ --color-md-user-fence-text: #ffffff;
158163 --color-md-hr: rgba(255, 255, 255, 0.08);
159164
165+ /* iMessage-style user bubble */
166+ --color-user-bubble: #0a84ff;
167+ --color-user-bubble-foreground: #ffffff;
168+ --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);
169+
160170 /* Drawer / modal backdrop */
161171 --color-backdrop: rgba(0, 0, 0, 0.48);
162172 --color-drawer: rgba(14, 14, 14, 0.99);
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift+9−1
@@ -29,6 +29,14 @@ public class T3ReviewDiffModule: Module { · 28 unmodified lines
2929 view.setViewedFileIdsJson(viewedFileIdsJson)
3030 }
3131
32+ Prop("selectedRowIdsJson") { (view: T3ReviewDiffView, selectedRowIdsJson: String) in
33+ view.setSelectedRowIdsJson(selectedRowIdsJson)
34+ }
35+
36+ Prop("collapsedCommentIdsJson") { (view: T3ReviewDiffView, collapsedCommentIdsJson: String) in
37+ view.setCollapsedCommentIdsJson(collapsedCommentIdsJson)
38+ }
39+
3240 Prop("appearanceScheme") { (view: T3ReviewDiffView, appearanceScheme: String) in
3341 view.setAppearanceScheme(appearanceScheme)
3442 }
@@ -49,7 +57,7 @@ public class T3ReviewDiffModule: Module { · 14 unmodified lines
4957 view.setContentWidth(CGFloat(contentWidth))
5058 }
5159
52− Events("onDebug", "onToggleFile", "onToggleViewedFile")
60+ Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment")
5361 }
5462 }
5563 }
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+194−0
@@ -16,6 +16,9 @@ private struct ReviewDiffNativeRow: Decodable { · 15 unmodified lines
1616 let oldLineNumber: Int?
1717 let newLineNumber: Int?
1818 let wordDiffRanges: [ReviewDiffNativeWordDiffRange]?
19+ let commentText: String?
20+ let commentRangeLabel: String?
21+ let commentSectionTitle: String?
1922 }
2023
2124 private struct ReviewDiffNativeWordDiffRange: Decodable {
@@ -317,6 +320,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 295 unmodified lines
317320 let onDebug = EventDispatcher()
318321 let onToggleFile = EventDispatcher()
319322 let onToggleViewedFile = EventDispatcher()
323+ let onPressLine = EventDispatcher()
324+ let onToggleComment = EventDispatcher()
320325
321326 public required init(appContext: AppContext? = nil) {
322327 super.init(appContext: appContext)
@@ -340,6 +345,12 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 17 unmodified lines
340345 contentView.onToggleViewedFile = { [weak self] fileId in
341346 self?.onToggleViewedFile(["fileId": fileId])
342347 }
348+ contentView.onPressLine = { [weak self] payload in
349+ self?.onPressLine(payload)
350+ }
351+ contentView.onToggleComment = { [weak self] commentId in
352+ self?.onToggleComment(["commentId": commentId])
353+ }
343354 contentView.onDrawMetrics = { [weak self] metrics in
344355 self?.emitDebug("draw-metrics", metrics)
345356 }
@@ -487,6 +498,15 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 141 unmodified lines
487498 contentView.viewedFileIds = decodeFileIdSet(viewedFileIdsJson)
488499 }
489500
501+ func setSelectedRowIdsJson(_ selectedRowIdsJson: String) {
502+ contentView.selectedRowIds = decodeFileIdSet(selectedRowIdsJson)
503+ }
504+
505+ func setCollapsedCommentIdsJson(_ collapsedCommentIdsJson: String) {
506+ contentView.collapsedCommentIds = decodeFileIdSet(collapsedCommentIdsJson)
507+ updateContentMetrics()
508+ }
509+
490510 private func decodeFileIdSet(_ json: String) -> Set<String> {
491511 guard let data = json.data(using: .utf8) else {
492512 return []
@@ -718,6 +738,17 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 225 unmodified lines
718738 setNeedsDisplayForVisibleBounds()
719739 }
720740 }
741+ var selectedRowIds: Set<String> = [] {
742+ didSet {
743+ setNeedsDisplayForVisibleBounds()
744+ }
745+ }
746+ var collapsedCommentIds: Set<String> = [] {
747+ didSet {
748+ rebuildRowLayout()
749+ setNeedsDisplayForVisibleBounds()
750+ }
751+ }
721752 var style = ReviewDiffNativeStyle.resolve(nil) {
722753 didSet {
723754 tokenAttributedStringsByRowId.removeAll()
@@ -760,6 +791,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 36 unmodified lines
760791 var isVerticalScrollActive = false
761792 var onToggleFile: ((String) -> Void)?
762793 var onToggleViewedFile: ((String) -> Void)?
794+ var onPressLine: (([String: Any]) -> Void)?
795+ var onToggleComment: ((String) -> Void)?
763796 var onDrawMetrics: (([String: Any]) -> Void)?
764797
765798 private var stickyWidth: CGFloat {
@@ -780,6 +813,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 14 unmodified lines
780813 if row.kind == "notice" {
781814 return max(style.rowHeight * 2, 44)
782815 }
816+ if row.kind == "comment" {
817+ return collapsedCommentIds.contains(row.id) ? 44 : 124
818+ }
783819 return style.rowHeight
784820 }
785821
@@ -871,12 +907,22 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 85 unmodified lines
871907 return gesture
872908 }()
873909
910+ private lazy var longPressGesture: UILongPressGestureRecognizer = {
911+ let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
912+ gesture.delegate = self
913+ gesture.cancelsTouchesInView = false
914+ gesture.minimumPressDuration = 0.28
915+ return gesture
916+ }()
917+
874918 override init(frame: CGRect) {
875919 super.init(frame: frame)
876920 isOpaque = true
877921 contentMode = .redraw
878922 addGestureRecognizer(horizontalPanGesture)
879923 addGestureRecognizer(tapGesture)
924+ addGestureRecognizer(longPressGesture)
925+ tapGesture.require(toFail: longPressGesture)
880926 }
881927
882928 func invalidateVisibleViewport() {
@@ -893,6 +939,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 10 unmodified lines
893939 contentMode = .redraw
894940 addGestureRecognizer(horizontalPanGesture)
895941 addGestureRecognizer(tapGesture)
942+ addGestureRecognizer(longPressGesture)
943+ tapGesture.require(toFail: longPressGesture)
896944 }
897945
898946 override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
@@ -934,6 +982,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 35 unmodified lines
934982 }
935983
936984 let row = rows[rowIndex]
985+ if row.kind == "comment" {
986+ onToggleComment?(row.id)
987+ return
988+ }
989+
990+ if row.kind == "line" {
991+ onPressLine?(linePressPayload(for: row, gesture: "tap"))
992+ return
993+ }
994+
937995 guard row.kind == "file" else {
938996 return
939997 }
@@ -943,6 +1001,45 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 3 unmodified lines
9431001 handleFileHeaderTap(row: row, rect: rect, point: point)
9441002 }
9451003
1004+ @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
1005+ guard gesture.state == .began else {
1006+ return
1007+ }
1008+
1009+ let point = gesture.location(in: self)
1010+ guard let rowIndex = rowIndex(at: verticalOffset + point.y),
1011+ rows.indices.contains(rowIndex) else {
1012+ return
1013+ }
1014+
1015+ let row = rows[rowIndex]
1016+ guard row.kind == "line" else {
1017+ return
1018+ }
1019+
1020+ onPressLine?(linePressPayload(for: row, gesture: "longPress"))
1021+ }
1022+
1023+ private func linePressPayload(for row: ReviewDiffNativeRow, gesture: String) -> [String: Any] {
1024+ var payload: [String: Any] = [
1025+ "rowId": row.id,
1026+ "fileId": resolvedFileId(for: row),
1027+ "gesture": gesture
1028+ ]
1029+
1030+ if let oldLineNumber = row.oldLineNumber {
1031+ payload["oldLineNumber"] = oldLineNumber
1032+ }
1033+ if let newLineNumber = row.newLineNumber {
1034+ payload["newLineNumber"] = newLineNumber
1035+ }
1036+ if let change = row.change {
1037+ payload["change"] = change
1038+ }
1039+
1040+ return payload
1041+ }
1042+
9461043 private func handleFileHeaderTap(row: ReviewDiffNativeRow, rect: CGRect, point: CGPoint) {
9471044 let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: rect)
9481045 let fileId = resolvedFileId(for: row)
@@ -1418,6 +1515,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 469 unmodified lines
14181515 drawHunkRow(row, rect: fullRect, context: context)
14191516 case "notice":
14201517 drawNoticeRow(row, rect: fullRect, context: context)
1518+ case "comment":
1519+ drawCommentRow(row, rect: fullRect, context: context)
14211520 default:
14221521 drawCodeRow(row, rect: fullRect, context: context)
14231522 }
@@ -1524,6 +1623,59 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 100 unmodified lines
15241623 )
15251624 }
15261625
1626+ private func drawCommentRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
1627+ guard !collapsedFileIds.contains(resolvedFileId(for: row)) else {
1628+ return
1629+ }
1630+
1631+ theme.background.setFill()
1632+ context.fill(rect)
1633+
1634+ let isCollapsed = collapsedCommentIds.contains(row.id)
1635+ let cardInset = CGFloat(8)
1636+ let cardRect = rect.insetBy(dx: cardInset, dy: 5)
1637+ let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 10)
1638+ theme.headerBackground.setFill()
1639+ cardPath.fill()
1640+ theme.border.withAlphaComponent(0.85).setStroke()
1641+ cardPath.lineWidth = 1
1642+ cardPath.stroke()
1643+
1644+ let chevronRect = CGRect(x: cardRect.minX + 10, y: cardRect.minY + 11, width: 16, height: 16)
1645+ drawDisclosureChevron(rect: chevronRect, color: theme.mutedText, collapsed: isCollapsed)
1646+
1647+ let title = "Comment on \(row.commentRangeLabel ?? "line")"
1648+ drawSingleLineText(
1649+ title,
1650+ rect: CGRect(
1651+ x: chevronRect.maxX + 10,
1652+ y: cardRect.minY + 8,
1653+ width: max(24, cardRect.width - chevronRect.maxX - 28),
1654+ height: fileHeaderSubtextFont.lineHeight + 4
1655+ ),
1656+ color: theme.mutedText,
1657+ font: fileHeaderSubtextFont
1658+ )
1659+
1660+ guard !isCollapsed else {
1661+ return
1662+ }
1663+
1664+ let body = row.commentText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
1665+ drawMultilineText(
1666+ body.isEmpty ? "Comment" : body,
1667+ rect: CGRect(
1668+ x: cardRect.minX + 18,
1669+ y: cardRect.minY + 42,
1670+ width: max(24, cardRect.width - 36),
1671+ height: max(20, cardRect.height - 56)
1672+ ),
1673+ color: theme.text,
1674+ font: fileHeaderSubtextFont,
1675+ maximumLineCount: 3
1676+ )
1677+ }
1678+
15271679 private func fileHeaderPathLayout(
15281680 for row: ReviewDiffNativeRow,
15291681 cardRect: CGRect
@@ -1762,6 +1914,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 232 unmodified lines
17621914 let change = row.change ?? "context"
17631915 rowBackground(for: change).setFill()
17641916 context.fill(rect)
1917+ drawSelectionOverlay(row, rect: rect, context: context)
17651918
17661919 if change == "add" {
17671920 theme.addBar.setFill()
@@ -1811,6 +1964,23 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 43 unmodified lines
18111964 context.restoreGState()
18121965 }
18131966
1967+ private func drawSelectionOverlay(
1968+ _ row: ReviewDiffNativeRow,
1969+ rect: CGRect,
1970+ context: CGContext
1971+ ) {
1972+ guard selectedRowIds.contains(row.id) else {
1973+ return
1974+ }
1975+
1976+ let selectionColor = theme.hunkText.withAlphaComponent(0.22)
1977+ selectionColor.setFill()
1978+ context.fill(rect)
1979+
1980+ theme.hunkText.withAlphaComponent(0.95).setFill()
1981+ context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height))
1982+ }
1983+
18141984 private func drawWordDiffRanges(
18151985 _ row: ReviewDiffNativeRow,
18161986 rowRect: CGRect,
@@ -1891,6 +2061,30 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 74 unmodified lines
18912061 (text as NSString).draw(in: rect, withAttributes: attributes)
18922062 }
18932063
2064+ private func drawMultilineText(
2065+ _ text: String,
2066+ rect: CGRect,
2067+ color: UIColor,
2068+ font: UIFont,
2069+ maximumLineCount: Int
2070+ ) {
2071+ let paragraphStyle = NSMutableParagraphStyle()
2072+ paragraphStyle.lineBreakMode = .byTruncatingTail
2073+ paragraphStyle.lineSpacing = 2
2074+
2075+ let attributes: [NSAttributedString.Key: Any] = [
2076+ .font: font,
2077+ .foregroundColor: color,
2078+ .paragraphStyle: paragraphStyle,
2079+ .ligature: 0,
2080+ ]
2081+ let maxHeight = CGFloat(maximumLineCount) * (font.lineHeight + paragraphStyle.lineSpacing)
2082+ (text as NSString).draw(
2083+ in: CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: min(rect.height, maxHeight)),
2084+ withAttributes: attributes
2085+ )
2086+ }
2087+
18942088 private func centeredTextY(in rect: CGRect, font: UIFont) -> CGFloat {
18952089 rect.midY - font.lineHeight / 2
18962090 }
apps/mobile/src/features/debug/native-review-diff/nativeReviewDiffModule.ts+17−1
@@ -11,7 +11,7 @@ interface ExpoGlobalWithViewConfig { · 10 unmodified lines
1111 }
1212
1313 export interface NativeReviewDiffRow {
14− readonly kind: "file" | "hunk" | "line" | "notice";
14+ readonly kind: "file" | "hunk" | "line" | "notice" | "comment";
1515 readonly id: string;
1616 readonly fileId?: string;
1717 readonly filePath?: string;
@@ -31,6 +31,9 @@ export interface NativeReviewDiffRow { · 13 unmodified lines
3131 readonly oldLineNumber?: number | null;
3232 readonly newLineNumber?: number | null;
3333 readonly wordDiffRanges?: ReadonlyArray<NativeReviewDiffWordDiffRange>;
34+ readonly commentText?: string;
35+ readonly commentRangeLabel?: string;
36+ readonly commentSectionTitle?: string;
3437 }
3538
3639 export interface NativeReviewDiffWordDiffRange {
@@ -100,6 +103,8 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 63 unmodified lines
100103 readonly tokensResetKey?: string;
101104 readonly collapsedFileIdsJson?: string;
102105 readonly viewedFileIdsJson?: string;
106+ readonly selectedRowIdsJson?: string;
107+ readonly collapsedCommentIdsJson?: string;
103108 readonly appearanceScheme: "light" | "dark";
104109 readonly themeJson: string;
105110 readonly styleJson?: string;
@@ -108,6 +113,17 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 2 unmodified lines
108113 readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;
109114 readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
110115 readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
116+ readonly onPressLine?: (
117+ event: NativeSyntheticEvent<{
118+ readonly rowId?: string;
119+ readonly fileId?: string;
120+ readonly gesture?: "tap" | "longPress";
121+ readonly oldLineNumber?: number;
122+ readonly newLineNumber?: number;
123+ readonly change?: "context" | "add" | "delete";
124+ }>,
125+ ) => void;
126+ readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void;
111127 }
112128
113129 let cachedNativeReviewDiffView: ComponentType<NativeReviewDiffViewProps> | undefined;
apps/mobile/src/features/review/reviewCommentSelection.test.ts+60−0
@@ -0,0 +1,60 @@
1+import { describe, expect, it } from "vitest";
2+
3+import {
4+ countReviewCommentContexts,
5+ formatReviewCommentContext,
6+ parseReviewInlineComments,
7+ type ReviewCommentTarget,
8+} from "./reviewCommentSelection";
9+
10+function makeTarget(): ReviewCommentTarget {
11+ return {
12+ sectionId: "section-1",
13+ sectionTitle: "Working tree",
14+ filePath: "apps/demo/src/main.ts",
15+ startIndex: 0,
16+ endIndex: 1,
17+ lines: [
18+ {
19+ kind: "line",
20+ id: "line-1",
21+ change: "delete",
22+ oldLineNumber: 7,
23+ newLineNumber: null,
24+ content: "const retryLimit = 2;",
25+ additionTokenIndex: null,
26+ deletionTokenIndex: 0,
27+ comparison: null,
28+ },
29+ {
30+ kind: "line",
31+ id: "line-2",
32+ change: "add",
33+ oldLineNumber: null,
34+ newLineNumber: 7,
35+ content: "const retryLimit = 4;",
36+ additionTokenIndex: 0,
37+ deletionTokenIndex: null,
38+ comparison: null,
39+ },
40+ ],
41+ };
42+}
43+
44+describe("review comment serialization", () => {
45+ it("preserves enough metadata for inline diff rendering", () => {
46+ const serialized = formatReviewCommentContext(makeTarget(), "Please keep this configurable.");
47+
48+ expect(countReviewCommentContexts(serialized)).toBe(1);
49+ expect(parseReviewInlineComments(serialized)).toEqual([
50+ expect.objectContaining({
51+ sectionId: "section-1",
52+ sectionTitle: "Working tree",
53+ filePath: "apps/demo/src/main.ts",
54+ startIndex: 0,
55+ endIndex: 1,
56+ text: "Please keep this configurable.",
57+ }),
58+ ]);
59+ });
60+});
apps/mobile/src/features/review/reviewCommentSelection.ts+86−3
@@ -3,6 +3,7 @@ import { useSyncExternalStore } from "react"; · 2 unmodified lines
33 import type { ReviewRenderableLineRow } from "./reviewModel";
44
55 export interface ReviewCommentTarget {
6+ readonly sectionId: string;
67 readonly sectionTitle: string;
78 readonly filePath: string;
89 readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
@@ -10,8 +11,21 @@ export interface ReviewCommentTarget { · 1 unmodified line
1011 readonly endIndex: number;
1112 }
1213
14+export interface ReviewInlineComment {
15+ readonly id: string;
16+ readonly sectionId: string;
17+ readonly sectionTitle: string;
18+ readonly filePath: string;
19+ readonly startIndex: number;
20+ readonly endIndex: number;
21+ readonly rangeLabel: string;
22+ readonly text: string;
23+}
24+
1325 let currentTarget: ReviewCommentTarget | null = null;
1426 const listeners = new Set<() => void>();
27+const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b([^>]*)>\s*([\s\S]*?)<\/review_comment>/g;
28+const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;
1529
1630 function emitChange() {
1731 listeners.forEach((listener) => listener());
@@ -73,11 +87,12 @@ export function getReviewChangeMarker(change: ReviewRenderableLineRow["change"]) · 55 unmodified lines
7387 }
7488
7589 export function buildReviewCommentTarget(
76− target: Pick<ReviewCommentTarget, "sectionTitle" | "filePath" | "lines">,
90+ target: Pick<ReviewCommentTarget, "sectionId" | "sectionTitle" | "filePath" | "lines">,
7791 anchorIndex: number,
7892 lineIndex: number,
7993 ): ReviewCommentTarget {
8094 return {
95+ sectionId: target.sectionId,
8196 sectionTitle: target.sectionTitle,
8297 filePath: target.filePath,
8398 lines: target.lines,
@@ -142,9 +157,51 @@ function formatReviewSelectedDiff(target: ReviewCommentTarget): string { · 58 unmodified lines
142157 ].join("\n");
143158 }
144159
160+function escapeReviewCommentAttribute(value: string): string {
161+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
162+}
163+
164+function unescapeReviewCommentAttribute(value: string): string {
165+ return value
166+ .replace(/&lt;/g, "<")
167+ .replace(/&quot;/g, '"')
168+ .replace(/&amp;/g, "&");
169+}
170+
171+function readReviewCommentAttributes(rawAttributes: string): Record<string, string> {
172+ const attributes: Record<string, string> = {};
173+ for (const match of rawAttributes.matchAll(REVIEW_COMMENT_ATTRIBUTE_PATTERN)) {
174+ attributes[match[1]!] = unescapeReviewCommentAttribute(match[2] ?? "");
175+ }
176+ return attributes;
177+}
178+
179+function readNonNegativeInteger(value: string | undefined): number | null {
180+ if (value === undefined || !/^\d+$/.test(value)) {
181+ return null;
182+ }
183+ return Number(value);
184+}
185+
186+function extractReviewCommentText(rawBody: string): string {
187+ const fenceIndex = rawBody.indexOf("```diff");
188+ const commentBody = fenceIndex >= 0 ? rawBody.slice(0, fenceIndex) : rawBody;
189+ return commentBody.trim();
190+}
191+
145192 export function formatReviewCommentContext(target: ReviewCommentTarget, comment: string): string {
193+ const rangeLabel = formatReviewSelectedRangeLabel(target);
146194 return [
147− "<review_comment>",
195+ [
196+ "<review_comment",
197+ ` sectionId="${escapeReviewCommentAttribute(target.sectionId)}"`,
198+ ` sectionTitle="${escapeReviewCommentAttribute(target.sectionTitle)}"`,
199+ ` filePath="${escapeReviewCommentAttribute(target.filePath)}"`,
200+ ` startIndex="${target.startIndex}"`,
201+ ` endIndex="${target.endIndex}"`,
202+ ` rangeLabel="${escapeReviewCommentAttribute(rangeLabel)}"`,
203+ ">",
204+ ].join(""),
148205 comment.trim(),
149206 "```diff",
150207 formatReviewSelectedDiff(target),
@@ -154,5 +211,31 @@ export function formatReviewCommentContext(target: ReviewCommentTarget, comment: · 3 unmodified lines
154211 }
155212
156213 export function countReviewCommentContexts(value: string): number {
157− return Array.from(value.matchAll(/<review_comment>/g)).length;
214+ return Array.from(value.matchAll(/<review_comment\b/g)).length;
215+}
216+
217+export function parseReviewInlineComments(value: string): ReadonlyArray<ReviewInlineComment> {
218+ const comments: ReviewInlineComment[] = [];
219+ for (const [index, match] of Array.from(value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)).entries()) {
220+ const attributes = readReviewCommentAttributes(match[1] ?? "");
221+ const startIndex = readNonNegativeInteger(attributes.startIndex);
222+ const endIndex = readNonNegativeInteger(attributes.endIndex);
223+ const filePath = attributes.filePath?.trim();
224+ const sectionId = attributes.sectionId?.trim();
225+ if (!filePath || !sectionId || startIndex === null || endIndex === null) {
226+ continue;
227+ }
228+
229+ comments.push({
230+ id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`,
231+ sectionId,
232+ sectionTitle: attributes.sectionTitle?.trim() || "Review",
233+ filePath,
234+ startIndex: Math.min(startIndex, endIndex),
235+ endIndex: Math.max(startIndex, endIndex),
236+ rangeLabel: attributes.rangeLabel?.trim() || "line",
237+ text: extractReviewCommentText(match[2] ?? ""),
238+ });
239+ }
240+ return comments;
158241 }
apps/mobile/src/features/threads/ThreadFeed.tsx+51−12
@@ -110,6 +110,7 @@ function useMarkdownStyles(): MarkdownStyleSets { · 109 unmodified lines
110110 const codeBg = useThemeColor("--color-md-code-bg");
111111 const codeText = useThemeColor("--color-md-code-text");
112112 const hrColor = useThemeColor("--color-md-hr");
113+ const userBodyColor = useThemeColor("--color-user-bubble-foreground");
113114 const userCodeBg = useThemeColor("--color-md-user-code-bg");
114115 const userCodeText = useThemeColor("--color-md-user-code-text");
115116 const userFenceBg = useThemeColor("--color-md-user-fence-bg");
@@ -124,6 +125,7 @@ function useMarkdownStyles(): MarkdownStyleSets { · 8 unmodified lines
124125 const markdownCodeBg = toMarkdownThemeColor(codeBg);
125126 const markdownCodeText = toMarkdownThemeColor(codeText);
126127 const markdownHrColor = toMarkdownThemeColor(hrColor);
128+ const markdownUserBodyColor = toMarkdownThemeColor(userBodyColor);
127129 const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg);
128130 const markdownUserCodeText = toMarkdownThemeColor(userCodeText);
129131 const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg);
@@ -181,9 +183,17 @@ function useMarkdownStyles(): MarkdownStyleSets { · 51 unmodified lines
181183 list: { marginTop: 4, marginBottom: 4 },
182184 list_item: { marginTop: 0, marginBottom: 4 },
183185 task_list_item: { marginTop: 0, marginBottom: 4 },
184− bold: { fontWeight: "700", color: markdownStrongColor, fontFamily: "DMSans_700Bold" },
186+ text: { lineHeight: 22 },
187+ bold: {
188+ fontWeight: "700",
189+ color: markdownStrongColor,
190+ fontFamily: "DMSans_700Bold",
191+ },
185192 italic: { fontStyle: "italic" },
186− link: { color: markdownLinkColor, textDecorationLine: "underline" as const },
193+ link: {
194+ color: markdownLinkColor,
195+ textDecorationLine: "underline" as const,
196+ },
187197 blockquote: {
188198 borderLeftWidth: 3,
189199 borderLeftColor: markdownBlockquoteBorder,
@@ -258,6 +268,9 @@ function useMarkdownStyles(): MarkdownStyleSets { · 68 unmodified lines
258268 ...baseTheme,
259269 colors: {
260270 ...baseTheme.colors,
271+ text: markdownUserBodyColor,
272+ heading: markdownUserBodyColor,
273+ link: markdownUserBodyColor,
261274 code: markdownUserCodeText,
262275 codeBackground: markdownUserCodeBg,
263276 border: markdownUserFenceBg,
@@ -266,6 +279,19 @@ function useMarkdownStyles(): MarkdownStyleSets { · 2 unmodified lines
266279 const userStyles: NodeStyleOverrides = {
267280 ...baseStyles,
268281 paragraph: { marginTop: 0, marginBottom: 0 },
282+ bold: {
283+ fontWeight: "700",
284+ color: markdownUserBodyColor,
285+ fontFamily: "DMSans_700Bold",
286+ },
287+ heading: {
288+ ...baseStyles.heading,
289+ color: markdownUserBodyColor,
290+ },
291+ link: {
292+ color: markdownUserBodyColor,
293+ textDecorationLine: "underline" as const,
294+ },
269295 };
270296
271297 const assistantTheme: PartialMarkdownTheme = {
@@ -312,6 +338,7 @@ function useMarkdownStyles(): MarkdownStyleSets { · 40 unmodified lines
312338 hrColor,
313339 linkColor,
314340 strongColor,
341+ userBodyColor,
315342 userCodeBg,
316343 userCodeText,
317344 userFenceBg,
@@ -328,11 +355,12 @@ function renderFeedEntry( · 10 unmodified lines
328355 readonly onToggleWorkGroup: (groupId: string) => void;
329356 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
330357 readonly iconSubtleColor: string | import("react-native").ColorValue;
358+ readonly userBubbleColor: string | import("react-native").ColorValue;
331359 readonly markdownStyles: MarkdownStyleSets;
332360 },
333361 ) {
334362 const entry = info.item;
335− const { markdownStyles, iconSubtleColor } = props;
363+ const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
336364
337365 if (entry.type === "message") {
338366 const { message } = entry;
@@ -344,7 +372,10 @@ function renderFeedEntry( · 5 unmodified lines
344372 if (isUser) {
345373 return (
346374 <View className="mb-3.5 items-end gap-1.5">
347− <View className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[10px] border border-blue-300/50 bg-blue-50/80 px-4 py-4 dark:border-blue-400/20 dark:bg-blue-500/12">
375+ <View
376+ className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[6px] px-3.5 py-2.5"
377+ style={{ backgroundColor: userBubbleColor }}
378+ >
348379 {message.text.trim().length > 0 ? (
349380 <Markdown
350381 options={{ gfm: true }}
@@ -372,14 +403,14 @@ function renderFeedEntry( · 21 unmodified lines
372403 >
373404 <Image
374405 source={{ uri, ...(headers ? { headers } : {}) }}
375− className="aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800"
406+ className="aspect-[1.3] w-full rounded-[14px] bg-white/15"
376407 resizeMode="cover"
377408 />
378409 </TouchableOpacity>
379410 );
380411 })}
381412 </View>
382− <Text className="px-1 text-right font-t3-medium text-xs text-neutral-500 dark:text-neutral-500">
413+ <Text className="px-1 text-right font-t3-medium text-xs text-neutral-600 dark:text-neutral-400">
383414 {timestampLabel}
384415 </Text>
385416 </View>
@@ -427,7 +458,7 @@ function renderFeedEntry( · 41 unmodified lines
427458 </TouchableOpacity>
428459 );
429460 })}
430− <Text className="font-t3-medium text-xs text-neutral-500 dark:text-neutral-500">
461+ <Text className="font-t3-medium text-xs text-neutral-600 dark:text-neutral-400">
431462 {timestampLabel}
432463 </Text>
433464 </View>
@@ -437,18 +468,21 @@ function renderFeedEntry( · 3 unmodified lines
437468 if (entry.type === "queued-message") {
438469 return (
439470 <View className="mb-3.5 gap-1.5 items-end">
440− <View className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[10px] border border-sky-300/60 bg-sky-100/75 px-4 py-4 dark:border-sky-300/20 dark:bg-sky-400/10">
441− <Text className="font-sans text-[15px] leading-[22px] text-neutral-950 dark:text-neutral-50">
471+ <View
472+ className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[6px] px-3.5 py-2.5 opacity-60"
473+ style={{ backgroundColor: userBubbleColor }}
474+ >
475+ <Text className="font-sans text-[15px] leading-[22px] text-white">
442476 {entry.queuedMessage.text}
443477 </Text>
444478 {entry.queuedMessage.attachments.length > 0 ? (
445− <Text className="font-t3-medium text-xs text-neutral-500 dark:text-neutral-500">
479+ <Text className="font-t3-medium text-xs text-white/75">
446480 {entry.queuedMessage.attachments.length} image
447481 {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached
448482 </Text>
449483 ) : null}
450484 </View>
451− <Text className="px-1 text-right font-t3-medium text-xs text-neutral-500 dark:text-neutral-500">
485+ <Text className="px-1 text-right font-t3-medium text-xs text-neutral-600 dark:text-neutral-400">
452486 {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`}
453487 </Text>
454488 </View>
@@ -537,6 +571,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 82 unmodified lines
537571 const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;
538572
539573 const iconSubtleColor = useThemeColor("--color-icon-subtle");
574+ const userBubbleColor = useThemeColor("--color-user-bubble");
540575 const markdownStyles = useMarkdownStyles();
541576
542577 useEffect(() => {
@@ -599,12 +634,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 56 unmodified lines
599634 onToggleWorkGroup,
600635 onPressImage,
601636 iconSubtleColor,
637+ userBubbleColor,
602638 markdownStyles,
603639 }),
604640 [
605641 copiedRowId,
606642 expandedWorkGroups,
607643 iconSubtleColor,
644+ userBubbleColor,
608645 markdownStyles,
609646 onCopyWorkRow,
610647 onPressImage,
@@ -652,7 +689,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 41 unmodified lines
652689 keyboardShouldPersistTaps="handled"
653690 estimatedItemSize={80}
654691 initialScrollAtEnd
655− maintainScrollAtEnd={{ on: { layout: true, itemLayout: true, dataChange: true } }}
692+ maintainScrollAtEnd={{
693+ on: { layout: true, itemLayout: true, dataChange: true },
694+ }}
656695 maintainScrollAtEndThreshold={0.1}
657696 refreshing={props.refreshing ?? false}
658697 onRefresh={props.onRefresh}
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+76−1
@@ -171,6 +171,7 @@ async function mountTerminalViewport(props: { · 170 unmodified lines
171171 threadRef: ReturnType<typeof scopeThreadRef>;
172172 drawerBackgroundColor?: string;
173173 drawerTextColor?: string;
174+ runtimeEnv?: Record<string, string>;
174175 }) {
175176 const drawer = document.createElement("div");
176177 drawer.className = "thread-terminal-drawer";
@@ -194,6 +195,7 @@ async function mountTerminalViewport(props: { · 17 unmodified lines
194195 terminalId="term-1"
195196 terminalLabel="Terminal"
196197 cwd="/repo/project"
198+ {...(props.runtimeEnv ? { runtimeEnv: props.runtimeEnv } : {})}
197199 onSessionExited={() => undefined}
198200 onAddTerminalContext={() => undefined}
199201 focusRequestId={0}
@@ -206,7 +208,10 @@ async function mountTerminalViewport(props: { · 6 unmodified lines
206208 );
207209
208210 return {
209− rerender: async (nextProps: { threadRef: ReturnType<typeof scopeThreadRef> }) => {
211+ rerender: async (nextProps: {
212+ threadRef: ReturnType<typeof scopeThreadRef>;
213+ runtimeEnv?: Record<string, string>;
214+ }) => {
210215 await screen.rerender(
211216 <TerminalViewport
212217 threadRef={nextProps.threadRef}
@@ -214,6 +219,7 @@ async function mountTerminalViewport(props: { · 1 unmodified line
214219 terminalId="term-1"
215220 terminalLabel="Terminal"
216221 cwd="/repo/project"
222+ {...(nextProps.runtimeEnv ? { runtimeEnv: nextProps.runtimeEnv } : {})}
217223 onSessionExited={() => undefined}
218224 onAddTerminalContext={() => undefined}
219225 focusRequestId={0}
@@ -259,6 +265,47 @@ describe("TerminalViewport", () => { · 39 unmodified lines
259265 }
260266 });
261267
268+ it("renders and attaches the terminal without the desktop local API", async () => {
269+ const environment = createEnvironmentApi();
270+ environmentApiById.set("environment-a", environment);
271+ readLocalApiMock.mockReturnValueOnce(undefined);
272+
273+ const mounted = await mountTerminalViewport({
274+ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
275+ });
276+
277+ try {
278+ await vi.waitFor(() => {
279+ expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
280+ });
281+ expect(terminalConstructorSpy).toHaveBeenCalledTimes(1);
282+ } finally {
283+ await mounted.cleanup();
284+ }
285+ });
286+
287+ it("keeps the terminal mounted when xterm fit runs before dimensions are ready", async () => {
288+ const environment = createEnvironmentApi();
289+ environmentApiById.set("environment-a", environment);
290+ fitAddonFitSpy.mockImplementationOnce(() => {
291+ throw new TypeError("Cannot read properties of undefined (reading 'dimensions')");
292+ });
293+
294+ const mounted = await mountTerminalViewport({
295+ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
296+ });
297+
298+ try {
299+ await vi.waitFor(() => {
300+ expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
301+ });
302+ expect(terminalConstructorSpy).toHaveBeenCalledTimes(1);
303+ expect(fitAddonFitSpy).toHaveBeenCalled();
304+ } finally {
305+ await mounted.cleanup();
306+ }
307+ });
308+
262309 it("reattaches the terminal when the scoped thread reference changes", async () => {
263310 const environmentA = createEnvironmentApi();
264311 const environmentB = createEnvironmentApi();
@@ -313,6 +360,34 @@ describe("TerminalViewport", () => { · 48 unmodified lines
313360 }
314361 });
315362
363+ it("does not reattach when runtime env contents are unchanged but object identity changes", async () => {
364+ const environment = createEnvironmentApi();
365+ environmentApiById.set("environment-a", environment);
366+
367+ const mounted = await mountTerminalViewport({
368+ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
369+ runtimeEnv: { PATH: "/usr/bin", T3: "1" },
370+ });
371+
372+ try {
373+ await vi.waitFor(() => {
374+ expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
375+ });
376+
377+ await mounted.rerender({
378+ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
379+ runtimeEnv: { T3: "1", PATH: "/usr/bin" },
380+ });
381+
382+ await vi.waitFor(() => {
383+ expect(environment.terminal.attach).toHaveBeenCalledTimes(1);
384+ });
385+ expect(terminalDisposeSpy).not.toHaveBeenCalled();
386+ } finally {
387+ await mounted.cleanup();
388+ }
389+ });
390+
316391 it("uses the drawer surface colors for the terminal theme", async () => {
317392 const environment = createEnvironmentApi();
318393 environmentApiById.set("environment-a", environment);
apps/web/src/components/ThreadTerminalDrawer.tsx+33−6
@@ -75,6 +75,24 @@ function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnap · 74 unmodified lines
7575 }
7676 }
7777
78+function fitTerminalSafely(fitAddon: FitAddon): boolean {
79+ try {
80+ fitAddon.fit();
81+ return true;
82+ } catch {
83+ return false;
84+ }
85+}
86+
87+function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string {
88+ if (!runtimeEnv) return "";
89+ return JSON.stringify(
90+ Object.entries(runtimeEnv)
91+ .filter(([key, value]) => key.length > 0 && typeof value === "string")
92+ .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)),
93+ );
94+}
95+
7896 function normalizeComputedColor(value: string | null | undefined, fallback: string): string {
7997 const normalizedValue = value?.trim().toLowerCase();
8098 if (
@@ -283,6 +301,7 @@ export function TerminalViewport({ · 202 unmodified lines
283301 const selectionActionOpenRef = useRef(false);
284302 const selectionActionTimerRef = useRef<number | null>(null);
285303 const keybindingsRef = useRef(keybindings);
304+ const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]);
286305 const handleSessionExited = useEffectEvent(() => {
287306 onSessionExited();
288307 });
@@ -302,7 +321,7 @@ export function TerminalViewport({ · 13 unmodified lines
302321 let disposed = false;
303322 const api = readEnvironmentApi(environmentId);
304323 const localApi = readLocalApi();
305− if (!api || !localApi) return;
324+ if (!api) return;
306325
307326 const fitAddon = new FitAddon();
308327 const terminal = new Terminal({
@@ -315,7 +334,7 @@ export function TerminalViewport({ · 6 unmodified lines
315334 });
316335 terminal.loadAddon(fitAddon);
317336 terminal.open(mount);
318− fitAddon.fit();
337+ fitTerminalSafely(fitAddon);
319338
320339 terminalRef.current = terminal;
321340 fitAddonRef.current = fitAddon;
@@ -369,6 +388,10 @@ export function TerminalViewport({ · 47 unmodified lines
369388 };
370389
371390 const showSelectionAction = async () => {
391+ if (!localApi) {
392+ clearSelectionAction();
393+ return;
394+ }
372395 if (selectionActionOpenRef.current) {
373396 return;
374397 }
@@ -479,6 +502,10 @@ export function TerminalViewport({ · 104 unmodified lines
479502
480503 const latestTerminal = terminalRef.current;
481504 if (!latestTerminal) return;
505+ if (!localApi) {
506+ writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser.");
507+ return;
508+ }
482509
483510 if (match.kind === "url") {
484511 void localApi.shell.openExternal(match.text).catch((error: unknown) => {
@@ -630,7 +657,7 @@ export function TerminalViewport({ · 145 unmodified lines
630657 const activeTerminal = terminalRef.current;
631658 const activeFitAddon = fitAddonRef.current;
632659 if (!activeTerminal || !activeFitAddon) return;
633− activeFitAddon.fit();
660+ fitTerminalSafely(activeFitAddon);
634661 unsubscribeAttach = attachTerminalSession({
635662 environmentId,
636663 client: api,
@@ -664,7 +691,7 @@ export function TerminalViewport({ · 27 unmodified lines
664691 if (!activeTerminal || !activeFitAddon) return;
665692 const wasAtBottom =
666693 activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY;
667− activeFitAddon.fit();
694+ fitTerminalSafely(activeFitAddon);
668695 if (wasAtBottom) {
669696 activeTerminal.scrollToBottom();
670697 }
@@ -700,7 +727,7 @@ export function TerminalViewport({ · 29 unmodified lines
700727 // autoFocus is intentionally omitted;
701728 // it is only read at mount time and must not trigger terminal teardown/recreation.
702729 // eslint-disable-next-line react-hooks/exhaustive-deps
703− }, [cwd, environmentId, runtimeEnv, terminalId, threadId, worktreePath]);
730+ }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);
704731
705732 useEffect(() => {
706733 if (!autoFocus) return;
@@ -721,7 +748,7 @@ export function TerminalViewport({ · 14 unmodified lines
721748 if (!api || !terminal || !fitAddon) return;
722749 const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;
723750 const frame = window.requestAnimationFrame(() => {
724− fitAddon.fit();
751+ fitTerminalSafely(fitAddon);
725752 if (wasAtBottom) {
726753 terminal.scrollToBottom();
727754 }
apps/web/src/lib/contextWindow.test.ts+17−0
@@ -44,6 +44,23 @@ describe("contextWindow", () => { · 43 unmodified lines
4444 expect(snapshot).toBeNull();
4545 });
4646
47+ it("keeps valid zero-usage snapshots", () => {
48+ const snapshot = deriveLatestContextWindowSnapshot([
49+ makeActivity("activity-1", "context-window.updated", {
50+ usedTokens: 0,
51+ maxTokens: 100_000,
52+ }),
53+ ]);
54+
55+ expect(snapshot).toMatchObject({
56+ usedTokens: 0,
57+ maxTokens: 100_000,
58+ remainingTokens: 100_000,
59+ usedPercentage: 0,
60+ remainingPercentage: 100,
61+ });
62+ });
63+
4764 it("formats compact token counts", () => {
4865 expect(formatContextWindowTokens(999)).toBe("999");
4966 expect(formatContextWindowTokens(1400)).toBe("1.4k");
apps/web/src/lib/contextWindow.ts+1−1
@@ -36,7 +36,7 @@ export function deriveLatestContextWindowSnapshot( · 35 unmodified lines
3636
3737 const payload = asRecord(activity.payload);
3838 const usedTokens = asFiniteNumber(payload?.usedTokens);
39− if (usedTokens === null || usedTokens <= 0) {
39+ if (usedTokens === null || usedTokens < 0) {
4040 continue;
4141 }
4242
apps/web/src/lib/lruCache.test.ts+9−0
@@ -40,4 +40,13 @@ describe("LRUCache", () => { · 39 unmodified lines
4040 expect(cache.get("b")).toBe("B");
4141 expect(cache.get("c")).toBe("C");
4242 });
43+
44+ it("does not cache entries larger than the memory budget", () => {
45+ const cache = new LRUCache<string>(2, 25);
46+ cache.set("a", "A", 10);
47+ cache.set("oversized", "X", 30);
48+
49+ expect(cache.get("a")).toBe("A");
50+ expect(cache.get("oversized")).toBeNull();
51+ });
4352 });
apps/web/src/lib/lruCache.ts+4−0
@@ -29,6 +29,10 @@ export class LRUCache<T> { · 28 unmodified lines
2929 this.cache.delete(key);
3030 }
3131
32+ if (approximateSize > this.maxMemoryBytes) {
33+ return;
34+ }
35+
3236 this.evictIfNeeded(approximateSize);
3337 this.cache.set(key, { value, approximateSize });
3438 this.totalSize += approximateSize;
apps/web/src/lib/threadSort.test.ts+48−0
@@ -114,6 +114,31 @@ describe("sortThreads", () => { · 113 unmodified lines
114114 ]);
115115 });
116116
117+ it("falls back to createdAt when updatedAt is invalid", () => {
118+ const sorted = sortThreads(
119+ [
120+ makeThread({
121+ id: ThreadId.make("thread-1"),
122+ createdAt: "2026-03-09T10:00:00.000Z",
123+ updatedAt: "invalid-date" as never,
124+ messages: [],
125+ }),
126+ makeThread({
127+ id: ThreadId.make("thread-2"),
128+ createdAt: "2026-03-09T09:00:00.000Z",
129+ updatedAt: "2026-03-09T09:30:00.000Z",
130+ messages: [],
131+ }),
132+ ],
133+ "updated_at",
134+ );
135+
136+ expect(sorted.map((thread) => thread.id)).toEqual([
137+ ThreadId.make("thread-1"),
138+ ThreadId.make("thread-2"),
139+ ]);
140+ });
141+
117142 it("falls back to id ordering when threads have no sortable timestamps", () => {
118143 const sorted = sortThreads(
119144 [
@@ -162,6 +187,29 @@ describe("sortThreads", () => { · 42 unmodified lines
162187 ]);
163188 });
164189
190+ it("uses updatedAt as a fallback for created_at sorting when createdAt is invalid", () => {
191+ const sorted = sortThreads(
192+ [
193+ makeThread({
194+ id: ThreadId.make("thread-1"),
195+ createdAt: "invalid-date" as never,
196+ updatedAt: "2026-03-09T10:05:00.000Z",
197+ }),
198+ makeThread({
199+ id: ThreadId.make("thread-2"),
200+ createdAt: "2026-03-09T10:00:00.000Z",
201+ updatedAt: "2026-03-09T10:10:00.000Z",
202+ }),
203+ ],
204+ "created_at",
205+ );
206+
207+ expect(sorted.map((thread) => thread.id)).toEqual([
208+ ThreadId.make("thread-1"),
209+ ThreadId.make("thread-2"),
210+ ]);
211+ });
212+
165213 it("returns the latest active thread for a project", () => {
166214 const latestThread = getLatestThreadForProject(
167215 [
apps/web/src/lib/threadSort.ts+15−2
@@ -13,6 +13,17 @@ export function toSortableTimestamp(iso: string | undefined): number | null { · 12 unmodified lines
1313 return Number.isFinite(ms) ? ms : null;
1414 }
1515
16+function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null {
17+ for (const value of values) {
18+ const timestamp = toSortableTimestamp(value ?? undefined);
19+ if (timestamp !== null) {
20+ return timestamp;
21+ }
22+ }
23+
24+ return null;
25+}
26+
1627 function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
1728 if (thread.latestUserMessageAt) {
1829 return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;
@@ -34,7 +45,7 @@ function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { · 15 unmodified lines
3445 return latestUserMessageTimestamp;
3546 }
3647
37− return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY;
48+ return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY;
3849 }
3950
4051 export function getThreadSortTimestamp(
@@ -42,7 +53,9 @@ export function getThreadSortTimestamp( · 1 unmodified line
4253 sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,
4354 ): number {
4455 if (sortOrder === "created_at") {
45− return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY;
56+ return (
57+ getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY
58+ );
4659 }
4760 return getLatestUserMessageTimestamp(thread);
4861 }