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
62 --color-status-bar: #f2f2f7;62 --color-status-bar: #f2f2f7;
63 63
64 /* Markdown */64 /* Markdown */
65 --color-md-body: #262626;65 --color-md-body: #111111;
66 --color-md-strong: #171717;66 --color-md-strong: #000000;
67 --color-md-link: #2563eb;67 --color-md-link: #2563eb;
68 --color-md-blockquote-border: rgba(0, 0, 0, 0.08);68 --color-md-blockquote-border: rgba(0, 0, 0, 0.08);
69 --color-md-blockquote-bg: rgba(0, 0, 0, 0.02);69 --color-md-blockquote-bg: rgba(0, 0, 0, 0.02);
70 --color-md-code-bg: rgba(0, 0, 0, 0.04);70 --color-md-code-bg: rgba(0, 0, 0, 0.04);
71 --color-md-code-text: #262626;71 --color-md-code-text: #262626;
72 --color-md-user-code-bg: rgba(255, 255, 255, 0.55);72 --color-md-user-code-bg: rgba(255, 255, 255, 0.22);
73 --color-md-user-code-text: #262626;73 --color-md-user-code-text: #ffffff;
74 --color-md-user-fence-bg: rgba(0, 0, 0, 0.04);74 --color-md-user-fence-bg: rgba(0, 0, 0, 0.16);
75 --color-md-user-fence-text: #1e3a5f;75 --color-md-user-fence-text: #ffffff;
76 --color-md-hr: rgba(0, 0, 0, 0.08);76 --color-md-hr: rgba(0, 0, 0, 0.08);
77 77
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
78 /* Drawer / modal backdrop */83 /* Drawer / modal backdrop */
79 --color-backdrop: rgba(0, 0, 0, 0.22);84 --color-backdrop: rgba(0, 0, 0, 0.22);
80 --color-drawer: rgba(255, 255, 255, 0.99);85 --color-drawer: rgba(255, 255, 255, 0.99);
@@ -151,12 +156,17 @@ · 70 unmodified lines
151 --color-md-blockquote-bg: rgba(255, 255, 255, 0.03);156 --color-md-blockquote-bg: rgba(255, 255, 255, 0.03);
152 --color-md-code-bg: rgba(255, 255, 255, 0.06);157 --color-md-code-bg: rgba(255, 255, 255, 0.06);
153 --color-md-code-text: #e5e5e5;158 --color-md-code-text: #e5e5e5;
154 --color-md-user-code-bg: rgba(255, 255, 255, 0.1);159 --color-md-user-code-bg: rgba(255, 255, 255, 0.18);
155 --color-md-user-code-text: #e5e5e5;160 --color-md-user-code-text: #ffffff;
156 --color-md-user-fence-bg: rgba(255, 255, 255, 0.08);161 --color-md-user-fence-bg: rgba(0, 0, 0, 0.28);
157 --color-md-user-fence-text: #e5e5e5;162 --color-md-user-fence-text: #ffffff;
158 --color-md-hr: rgba(255, 255, 255, 0.08);163 --color-md-hr: rgba(255, 255, 255, 0.08);
159 164
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
160 /* Drawer / modal backdrop */170 /* Drawer / modal backdrop */
161 --color-backdrop: rgba(0, 0, 0, 0.48);171 --color-backdrop: rgba(0, 0, 0, 0.48);
162 --color-drawer: rgba(14, 14, 14, 0.99);172 --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
29 view.setViewedFileIdsJson(viewedFileIdsJson)29 view.setViewedFileIdsJson(viewedFileIdsJson)
30 }30 }
31 31
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
32 Prop("appearanceScheme") { (view: T3ReviewDiffView, appearanceScheme: String) in40 Prop("appearanceScheme") { (view: T3ReviewDiffView, appearanceScheme: String) in
33 view.setAppearanceScheme(appearanceScheme)41 view.setAppearanceScheme(appearanceScheme)
34 }42 }
@@ -49,7 +57,7 @@ public class T3ReviewDiffModule: Module { · 14 unmodified lines
49 view.setContentWidth(CGFloat(contentWidth))57 view.setContentWidth(CGFloat(contentWidth))
50 }58 }
51 59
52 Events("onDebug", "onToggleFile", "onToggleViewedFile")60 Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment")
53 }61 }
54 }62 }
55}63}
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+194−0
@@ -16,6 +16,9 @@ private struct ReviewDiffNativeRow: Decodable { · 15 unmodified lines
16 let oldLineNumber: Int?16 let oldLineNumber: Int?
17 let newLineNumber: Int?17 let newLineNumber: Int?
18 let wordDiffRanges: [ReviewDiffNativeWordDiffRange]?18 let wordDiffRanges: [ReviewDiffNativeWordDiffRange]?
19 let commentText: String?
20 let commentRangeLabel: String?
21 let commentSectionTitle: String?
19}22}
20 23
21private struct ReviewDiffNativeWordDiffRange: Decodable {24private struct ReviewDiffNativeWordDiffRange: Decodable {
@@ -317,6 +320,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 295 unmodified lines
317 let onDebug = EventDispatcher()320 let onDebug = EventDispatcher()
318 let onToggleFile = EventDispatcher()321 let onToggleFile = EventDispatcher()
319 let onToggleViewedFile = EventDispatcher()322 let onToggleViewedFile = EventDispatcher()
323 let onPressLine = EventDispatcher()
324 let onToggleComment = EventDispatcher()
320 325
321 public required init(appContext: AppContext? = nil) {326 public required init(appContext: AppContext? = nil) {
322 super.init(appContext: appContext)327 super.init(appContext: appContext)
@@ -340,6 +345,12 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 17 unmodified lines
340 contentView.onToggleViewedFile = { [weak self] fileId in345 contentView.onToggleViewedFile = { [weak self] fileId in
341 self?.onToggleViewedFile(["fileId": fileId])346 self?.onToggleViewedFile(["fileId": fileId])
342 }347 }
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 }
343 contentView.onDrawMetrics = { [weak self] metrics in354 contentView.onDrawMetrics = { [weak self] metrics in
344 self?.emitDebug("draw-metrics", metrics)355 self?.emitDebug("draw-metrics", metrics)
345 }356 }
@@ -487,6 +498,15 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 141 unmodified lines
487 contentView.viewedFileIds = decodeFileIdSet(viewedFileIdsJson)498 contentView.viewedFileIds = decodeFileIdSet(viewedFileIdsJson)
488 }499 }
489 500
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
490 private func decodeFileIdSet(_ json: String) -> Set<String> {510 private func decodeFileIdSet(_ json: String) -> Set<String> {
491 guard let data = json.data(using: .utf8) else {511 guard let data = json.data(using: .utf8) else {
492 return []512 return []
@@ -718,6 +738,17 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 225 unmodified lines
718 setNeedsDisplayForVisibleBounds()738 setNeedsDisplayForVisibleBounds()
719 }739 }
720 }740 }
741 var selectedRowIds: Set<String> = [] {
742 didSet {
743 setNeedsDisplayForVisibleBounds()
744 }
745 }
746 var collapsedCommentIds: Set<String> = [] {
747 didSet {
748 rebuildRowLayout()
749 setNeedsDisplayForVisibleBounds()
750 }
751 }
721 var style = ReviewDiffNativeStyle.resolve(nil) {752 var style = ReviewDiffNativeStyle.resolve(nil) {
722 didSet {753 didSet {
723 tokenAttributedStringsByRowId.removeAll()754 tokenAttributedStringsByRowId.removeAll()
@@ -760,6 +791,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 36 unmodified lines
760 var isVerticalScrollActive = false791 var isVerticalScrollActive = false
761 var onToggleFile: ((String) -> Void)?792 var onToggleFile: ((String) -> Void)?
762 var onToggleViewedFile: ((String) -> Void)?793 var onToggleViewedFile: ((String) -> Void)?
794 var onPressLine: (([String: Any]) -> Void)?
795 var onToggleComment: ((String) -> Void)?
763 var onDrawMetrics: (([String: Any]) -> Void)?796 var onDrawMetrics: (([String: Any]) -> Void)?
764 797
765 private var stickyWidth: CGFloat {798 private var stickyWidth: CGFloat {
@@ -780,6 +813,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 14 unmodified lines
780 if row.kind == "notice" {813 if row.kind == "notice" {
781 return max(style.rowHeight * 2, 44)814 return max(style.rowHeight * 2, 44)
782 }815 }
816 if row.kind == "comment" {
817 return collapsedCommentIds.contains(row.id) ? 44 : 124
818 }
783 return style.rowHeight819 return style.rowHeight
784 }820 }
785 821
@@ -871,12 +907,22 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 85 unmodified lines
871 return gesture907 return gesture
872 }()908 }()
873 909
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
874 override init(frame: CGRect) {918 override init(frame: CGRect) {
875 super.init(frame: frame)919 super.init(frame: frame)
876 isOpaque = true920 isOpaque = true
877 contentMode = .redraw921 contentMode = .redraw
878 addGestureRecognizer(horizontalPanGesture)922 addGestureRecognizer(horizontalPanGesture)
879 addGestureRecognizer(tapGesture)923 addGestureRecognizer(tapGesture)
924 addGestureRecognizer(longPressGesture)
925 tapGesture.require(toFail: longPressGesture)
880 }926 }
881 927
882 func invalidateVisibleViewport() {928 func invalidateVisibleViewport() {
@@ -893,6 +939,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 10 unmodified lines
893 contentMode = .redraw939 contentMode = .redraw
894 addGestureRecognizer(horizontalPanGesture)940 addGestureRecognizer(horizontalPanGesture)
895 addGestureRecognizer(tapGesture)941 addGestureRecognizer(tapGesture)
942 addGestureRecognizer(longPressGesture)
943 tapGesture.require(toFail: longPressGesture)
896 }944 }
897 945
898 override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {946 override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
@@ -934,6 +982,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 35 unmodified lines
934 }982 }
935 983
936 let row = rows[rowIndex]984 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
937 guard row.kind == "file" else {995 guard row.kind == "file" else {
938 return996 return
939 }997 }
@@ -943,6 +1001,45 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 3 unmodified lines
943 handleFileHeaderTap(row: row, rect: rect, point: point)1001 handleFileHeaderTap(row: row, rect: rect, point: point)
944 }1002 }
945 1003
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
946 private func handleFileHeaderTap(row: ReviewDiffNativeRow, rect: CGRect, point: CGPoint) {1043 private func handleFileHeaderTap(row: ReviewDiffNativeRow, rect: CGRect, point: CGPoint) {
947 let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: rect)1044 let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: rect)
948 let fileId = resolvedFileId(for: row)1045 let fileId = resolvedFileId(for: row)
@@ -1418,6 +1515,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 469 unmodified lines
1418 drawHunkRow(row, rect: fullRect, context: context)1515 drawHunkRow(row, rect: fullRect, context: context)
1419 case "notice":1516 case "notice":
1420 drawNoticeRow(row, rect: fullRect, context: context)1517 drawNoticeRow(row, rect: fullRect, context: context)
1518 case "comment":
1519 drawCommentRow(row, rect: fullRect, context: context)
1421 default:1520 default:
1422 drawCodeRow(row, rect: fullRect, context: context)1521 drawCodeRow(row, rect: fullRect, context: context)
1423 }1522 }
@@ -1524,6 +1623,59 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 100 unmodified lines
1524 )1623 )
1525 }1624 }
1526 1625
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
1527 private func fileHeaderPathLayout(1679 private func fileHeaderPathLayout(
1528 for row: ReviewDiffNativeRow,1680 for row: ReviewDiffNativeRow,
1529 cardRect: CGRect1681 cardRect: CGRect
@@ -1762,6 +1914,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 232 unmodified lines
1762 let change = row.change ?? "context"1914 let change = row.change ?? "context"
1763 rowBackground(for: change).setFill()1915 rowBackground(for: change).setFill()
1764 context.fill(rect)1916 context.fill(rect)
1917 drawSelectionOverlay(row, rect: rect, context: context)
1765 1918
1766 if change == "add" {1919 if change == "add" {
1767 theme.addBar.setFill()1920 theme.addBar.setFill()
@@ -1811,6 +1964,23 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 43 unmodified lines
1811 context.restoreGState()1964 context.restoreGState()
1812 }1965 }
1813 1966
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
1814 private func drawWordDiffRanges(1984 private func drawWordDiffRanges(
1815 _ row: ReviewDiffNativeRow,1985 _ row: ReviewDiffNativeRow,
1816 rowRect: CGRect,1986 rowRect: CGRect,
@@ -1891,6 +2061,30 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 74 unmodified lines
1891 (text as NSString).draw(in: rect, withAttributes: attributes)2061 (text as NSString).draw(in: rect, withAttributes: attributes)
1892 }2062 }
1893 2063
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
1894 private func centeredTextY(in rect: CGRect, font: UIFont) -> CGFloat {2088 private func centeredTextY(in rect: CGRect, font: UIFont) -> CGFloat {
1895 rect.midY - font.lineHeight / 22089 rect.midY - font.lineHeight / 2
1896 }2090 }
apps/mobile/src/features/debug/native-review-diff/nativeReviewDiffModule.ts+17−1
@@ -11,7 +11,7 @@ interface ExpoGlobalWithViewConfig { · 10 unmodified lines
11}11}
12 12
13export interface NativeReviewDiffRow {13export interface NativeReviewDiffRow {
14 readonly kind: "file" | "hunk" | "line" | "notice";14 readonly kind: "file" | "hunk" | "line" | "notice" | "comment";
15 readonly id: string;15 readonly id: string;
16 readonly fileId?: string;16 readonly fileId?: string;
17 readonly filePath?: string;17 readonly filePath?: string;
@@ -31,6 +31,9 @@ export interface NativeReviewDiffRow { · 13 unmodified lines
31 readonly oldLineNumber?: number | null;31 readonly oldLineNumber?: number | null;
32 readonly newLineNumber?: number | null;32 readonly newLineNumber?: number | null;
33 readonly wordDiffRanges?: ReadonlyArray<NativeReviewDiffWordDiffRange>;33 readonly wordDiffRanges?: ReadonlyArray<NativeReviewDiffWordDiffRange>;
34 readonly commentText?: string;
35 readonly commentRangeLabel?: string;
36 readonly commentSectionTitle?: string;
34}37}
35 38
36export interface NativeReviewDiffWordDiffRange {39export interface NativeReviewDiffWordDiffRange {
@@ -100,6 +103,8 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 63 unmodified lines
100 readonly tokensResetKey?: string;103 readonly tokensResetKey?: string;
101 readonly collapsedFileIdsJson?: string;104 readonly collapsedFileIdsJson?: string;
102 readonly viewedFileIdsJson?: string;105 readonly viewedFileIdsJson?: string;
106 readonly selectedRowIdsJson?: string;
107 readonly collapsedCommentIdsJson?: string;
103 readonly appearanceScheme: "light" | "dark";108 readonly appearanceScheme: "light" | "dark";
104 readonly themeJson: string;109 readonly themeJson: string;
105 readonly styleJson?: string;110 readonly styleJson?: string;
@@ -108,6 +113,17 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 2 unmodified lines
108 readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;113 readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;
109 readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;114 readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
110 readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;115 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;
111}127}
112 128
113let cachedNativeReviewDiffView: ComponentType<NativeReviewDiffViewProps> | undefined;129let cachedNativeReviewDiffView: ComponentType<NativeReviewDiffViewProps> | undefined;
apps/mobile/src/features/review/reviewCommentSelection.test.ts+60−0
@@ -0,0 +1,60 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 countReviewCommentContexts,
5 formatReviewCommentContext,
6 parseReviewInlineComments,
7 type ReviewCommentTarget,
8} from "./reviewCommentSelection";
9
10function 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
44describe("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
3import type { ReviewRenderableLineRow } from "./reviewModel";3import type { ReviewRenderableLineRow } from "./reviewModel";
4 4
5export interface ReviewCommentTarget {5export interface ReviewCommentTarget {
6 readonly sectionId: string;
6 readonly sectionTitle: string;7 readonly sectionTitle: string;
7 readonly filePath: string;8 readonly filePath: string;
8 readonly lines: ReadonlyArray<ReviewRenderableLineRow>;9 readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
@@ -10,8 +11,21 @@ export interface ReviewCommentTarget { · 1 unmodified line
10 readonly endIndex: number;11 readonly endIndex: number;
11}12}
12 13
14export 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
13let currentTarget: ReviewCommentTarget | null = null;25let currentTarget: ReviewCommentTarget | null = null;
14const listeners = new Set<() => void>();26const listeners = new Set<() => void>();
27const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b([^>]*)>\s*([\s\S]*?)<\/review_comment>/g;
28const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;
15 29
16function emitChange() {30function emitChange() {
17 listeners.forEach((listener) => listener());31 listeners.forEach((listener) => listener());
@@ -73,11 +87,12 @@ export function getReviewChangeMarker(change: ReviewRenderableLineRow["change"]) · 55 unmodified lines
73}87}
74 88
75export function buildReviewCommentTarget(89export function buildReviewCommentTarget(
76 target: Pick<ReviewCommentTarget, "sectionTitle" | "filePath" | "lines">,90 target: Pick<ReviewCommentTarget, "sectionId" | "sectionTitle" | "filePath" | "lines">,
77 anchorIndex: number,91 anchorIndex: number,
78 lineIndex: number,92 lineIndex: number,
79): ReviewCommentTarget {93): ReviewCommentTarget {
80 return {94 return {
95 sectionId: target.sectionId,
81 sectionTitle: target.sectionTitle,96 sectionTitle: target.sectionTitle,
82 filePath: target.filePath,97 filePath: target.filePath,
83 lines: target.lines,98 lines: target.lines,
@@ -142,9 +157,51 @@ function formatReviewSelectedDiff(target: ReviewCommentTarget): string { · 58 unmodified lines
142 ].join("\n");157 ].join("\n");
143}158}
144 159
160function escapeReviewCommentAttribute(value: string): string {
161 return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
162}
163
164function unescapeReviewCommentAttribute(value: string): string {
165 return value
166 .replace(/&lt;/g, "<")
167 .replace(/&quot;/g, '"')
168 .replace(/&amp;/g, "&");
169}
170
171function 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
179function readNonNegativeInteger(value: string | undefined): number | null {
180 if (value === undefined || !/^\d+$/.test(value)) {
181 return null;
182 }
183 return Number(value);
184}
185
186function 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
145export function formatReviewCommentContext(target: ReviewCommentTarget, comment: string): string {192export function formatReviewCommentContext(target: ReviewCommentTarget, comment: string): string {
193 const rangeLabel = formatReviewSelectedRangeLabel(target);
146 return [194 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(""),
148 comment.trim(),205 comment.trim(),
149 "```diff",206 "```diff",
150 formatReviewSelectedDiff(target),207 formatReviewSelectedDiff(target),
@@ -154,5 +211,31 @@ export function formatReviewCommentContext(target: ReviewCommentTarget, comment: · 3 unmodified lines
154}211}
155 212
156export function countReviewCommentContexts(value: string): number {213export 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
217export 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;
158}241}
apps/mobile/src/features/threads/ThreadFeed.tsx+51−12
@@ -110,6 +110,7 @@ function useMarkdownStyles(): MarkdownStyleSets { · 109 unmodified lines
110 const codeBg = useThemeColor("--color-md-code-bg");110 const codeBg = useThemeColor("--color-md-code-bg");
111 const codeText = useThemeColor("--color-md-code-text");111 const codeText = useThemeColor("--color-md-code-text");
112 const hrColor = useThemeColor("--color-md-hr");112 const hrColor = useThemeColor("--color-md-hr");
113 const userBodyColor = useThemeColor("--color-user-bubble-foreground");
113 const userCodeBg = useThemeColor("--color-md-user-code-bg");114 const userCodeBg = useThemeColor("--color-md-user-code-bg");
114 const userCodeText = useThemeColor("--color-md-user-code-text");115 const userCodeText = useThemeColor("--color-md-user-code-text");
115 const userFenceBg = useThemeColor("--color-md-user-fence-bg");116 const userFenceBg = useThemeColor("--color-md-user-fence-bg");
@@ -124,6 +125,7 @@ function useMarkdownStyles(): MarkdownStyleSets { · 8 unmodified lines
124 const markdownCodeBg = toMarkdownThemeColor(codeBg);125 const markdownCodeBg = toMarkdownThemeColor(codeBg);
125 const markdownCodeText = toMarkdownThemeColor(codeText);126 const markdownCodeText = toMarkdownThemeColor(codeText);
126 const markdownHrColor = toMarkdownThemeColor(hrColor);127 const markdownHrColor = toMarkdownThemeColor(hrColor);
128 const markdownUserBodyColor = toMarkdownThemeColor(userBodyColor);
127 const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg);129 const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg);
128 const markdownUserCodeText = toMarkdownThemeColor(userCodeText);130 const markdownUserCodeText = toMarkdownThemeColor(userCodeText);
129 const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg);131 const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg);
@@ -181,9 +183,17 @@ function useMarkdownStyles(): MarkdownStyleSets { · 51 unmodified lines
181 list: { marginTop: 4, marginBottom: 4 },183 list: { marginTop: 4, marginBottom: 4 },
182 list_item: { marginTop: 0, marginBottom: 4 },184 list_item: { marginTop: 0, marginBottom: 4 },
183 task_list_item: { marginTop: 0, marginBottom: 4 },185 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 },
185 italic: { fontStyle: "italic" },192 italic: { fontStyle: "italic" },
186 link: { color: markdownLinkColor, textDecorationLine: "underline" as const },193 link: {
194 color: markdownLinkColor,
195 textDecorationLine: "underline" as const,
196 },
187 blockquote: {197 blockquote: {
188 borderLeftWidth: 3,198 borderLeftWidth: 3,
189 borderLeftColor: markdownBlockquoteBorder,199 borderLeftColor: markdownBlockquoteBorder,
@@ -258,6 +268,9 @@ function useMarkdownStyles(): MarkdownStyleSets { · 68 unmodified lines
258 ...baseTheme,268 ...baseTheme,
259 colors: {269 colors: {
260 ...baseTheme.colors,270 ...baseTheme.colors,
271 text: markdownUserBodyColor,
272 heading: markdownUserBodyColor,
273 link: markdownUserBodyColor,
261 code: markdownUserCodeText,274 code: markdownUserCodeText,
262 codeBackground: markdownUserCodeBg,275 codeBackground: markdownUserCodeBg,
263 border: markdownUserFenceBg,276 border: markdownUserFenceBg,
@@ -266,6 +279,19 @@ function useMarkdownStyles(): MarkdownStyleSets { · 2 unmodified lines
266 const userStyles: NodeStyleOverrides = {279 const userStyles: NodeStyleOverrides = {
267 ...baseStyles,280 ...baseStyles,
268 paragraph: { marginTop: 0, marginBottom: 0 },281 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 },
269 };295 };
270 296
271 const assistantTheme: PartialMarkdownTheme = {297 const assistantTheme: PartialMarkdownTheme = {
@@ -312,6 +338,7 @@ function useMarkdownStyles(): MarkdownStyleSets { · 40 unmodified lines
312 hrColor,338 hrColor,
313 linkColor,339 linkColor,
314 strongColor,340 strongColor,
341 userBodyColor,
315 userCodeBg,342 userCodeBg,
316 userCodeText,343 userCodeText,
317 userFenceBg,344 userFenceBg,
@@ -328,11 +355,12 @@ function renderFeedEntry( · 10 unmodified lines
328 readonly onToggleWorkGroup: (groupId: string) => void;355 readonly onToggleWorkGroup: (groupId: string) => void;
329 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;356 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
330 readonly iconSubtleColor: string | import("react-native").ColorValue;357 readonly iconSubtleColor: string | import("react-native").ColorValue;
358 readonly userBubbleColor: string | import("react-native").ColorValue;
331 readonly markdownStyles: MarkdownStyleSets;359 readonly markdownStyles: MarkdownStyleSets;
332 },360 },
333) {361) {
334 const entry = info.item;362 const entry = info.item;
335 const { markdownStyles, iconSubtleColor } = props;363 const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
336 364
337 if (entry.type === "message") {365 if (entry.type === "message") {
338 const { message } = entry;366 const { message } = entry;
@@ -344,7 +372,10 @@ function renderFeedEntry( · 5 unmodified lines
344 if (isUser) {372 if (isUser) {
345 return (373 return (
346 <View className="mb-3.5 items-end gap-1.5">374 <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 >
348 {message.text.trim().length > 0 ? (379 {message.text.trim().length > 0 ? (
349 <Markdown380 <Markdown
350 options={{ gfm: true }}381 options={{ gfm: true }}
@@ -372,14 +403,14 @@ function renderFeedEntry( · 21 unmodified lines
372 >403 >
373 <Image404 <Image
374 source={{ uri, ...(headers ? { headers } : {}) }}405 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"
376 resizeMode="cover"407 resizeMode="cover"
377 />408 />
378 </TouchableOpacity>409 </TouchableOpacity>
379 );410 );
380 })}411 })}
381 </View>412 </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">
383 {timestampLabel}414 {timestampLabel}
384 </Text>415 </Text>
385 </View>416 </View>
@@ -427,7 +458,7 @@ function renderFeedEntry( · 41 unmodified lines
427 </TouchableOpacity>458 </TouchableOpacity>
428 );459 );
429 })}460 })}
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">
431 {timestampLabel}462 {timestampLabel}
432 </Text>463 </Text>
433 </View>464 </View>
@@ -437,18 +468,21 @@ function renderFeedEntry( · 3 unmodified lines
437 if (entry.type === "queued-message") {468 if (entry.type === "queued-message") {
438 return (469 return (
439 <View className="mb-3.5 gap-1.5 items-end">470 <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">471 <View
441 <Text className="font-sans text-[15px] leading-[22px] text-neutral-950 dark:text-neutral-50">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">
442 {entry.queuedMessage.text}476 {entry.queuedMessage.text}
443 </Text>477 </Text>
444 {entry.queuedMessage.attachments.length > 0 ? (478 {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">
446 {entry.queuedMessage.attachments.length} image480 {entry.queuedMessage.attachments.length} image
447 {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached481 {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached
448 </Text>482 </Text>
449 ) : null}483 ) : null}
450 </View>484 </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">
452 {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`}486 {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`}
453 </Text>487 </Text>
454 </View>488 </View>
@@ -537,6 +571,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 82 unmodified lines
537 const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;571 const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;
538 572
539 const iconSubtleColor = useThemeColor("--color-icon-subtle");573 const iconSubtleColor = useThemeColor("--color-icon-subtle");
574 const userBubbleColor = useThemeColor("--color-user-bubble");
540 const markdownStyles = useMarkdownStyles();575 const markdownStyles = useMarkdownStyles();
541 576
542 useEffect(() => {577 useEffect(() => {
@@ -599,12 +634,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 56 unmodified lines
599 onToggleWorkGroup,634 onToggleWorkGroup,
600 onPressImage,635 onPressImage,
601 iconSubtleColor,636 iconSubtleColor,
637 userBubbleColor,
602 markdownStyles,638 markdownStyles,
603 }),639 }),
604 [640 [
605 copiedRowId,641 copiedRowId,
606 expandedWorkGroups,642 expandedWorkGroups,
607 iconSubtleColor,643 iconSubtleColor,
644 userBubbleColor,
608 markdownStyles,645 markdownStyles,
609 onCopyWorkRow,646 onCopyWorkRow,
610 onPressImage,647 onPressImage,
@@ -652,7 +689,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 41 unmodified lines
652 keyboardShouldPersistTaps="handled"689 keyboardShouldPersistTaps="handled"
653 estimatedItemSize={80}690 estimatedItemSize={80}
654 initialScrollAtEnd691 initialScrollAtEnd
655 maintainScrollAtEnd={{ on: { layout: true, itemLayout: true, dataChange: true } }}692 maintainScrollAtEnd={{
693 on: { layout: true, itemLayout: true, dataChange: true },
694 }}
656 maintainScrollAtEndThreshold={0.1}695 maintainScrollAtEndThreshold={0.1}
657 refreshing={props.refreshing ?? false}696 refreshing={props.refreshing ?? false}
658 onRefresh={props.onRefresh}697 onRefresh={props.onRefresh}
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+76−1
@@ -171,6 +171,7 @@ async function mountTerminalViewport(props: { · 170 unmodified lines
171 threadRef: ReturnType<typeof scopeThreadRef>;171 threadRef: ReturnType<typeof scopeThreadRef>;
172 drawerBackgroundColor?: string;172 drawerBackgroundColor?: string;
173 drawerTextColor?: string;173 drawerTextColor?: string;
174 runtimeEnv?: Record<string, string>;
174}) {175}) {
175 const drawer = document.createElement("div");176 const drawer = document.createElement("div");
176 drawer.className = "thread-terminal-drawer";177 drawer.className = "thread-terminal-drawer";
@@ -194,6 +195,7 @@ async function mountTerminalViewport(props: { · 17 unmodified lines
194 terminalId="term-1"195 terminalId="term-1"
195 terminalLabel="Terminal"196 terminalLabel="Terminal"
196 cwd="/repo/project"197 cwd="/repo/project"
198 {...(props.runtimeEnv ? { runtimeEnv: props.runtimeEnv } : {})}
197 onSessionExited={() => undefined}199 onSessionExited={() => undefined}
198 onAddTerminalContext={() => undefined}200 onAddTerminalContext={() => undefined}
199 focusRequestId={0}201 focusRequestId={0}
@@ -206,7 +208,10 @@ async function mountTerminalViewport(props: { · 6 unmodified lines
206 );208 );
207 209
208 return {210 return {
209 rerender: async (nextProps: { threadRef: ReturnType<typeof scopeThreadRef> }) => {211 rerender: async (nextProps: {
212 threadRef: ReturnType<typeof scopeThreadRef>;
213 runtimeEnv?: Record<string, string>;
214 }) => {
210 await screen.rerender(215 await screen.rerender(
211 <TerminalViewport216 <TerminalViewport
212 threadRef={nextProps.threadRef}217 threadRef={nextProps.threadRef}
@@ -214,6 +219,7 @@ async function mountTerminalViewport(props: { · 1 unmodified line
214 terminalId="term-1"219 terminalId="term-1"
215 terminalLabel="Terminal"220 terminalLabel="Terminal"
216 cwd="/repo/project"221 cwd="/repo/project"
222 {...(nextProps.runtimeEnv ? { runtimeEnv: nextProps.runtimeEnv } : {})}
217 onSessionExited={() => undefined}223 onSessionExited={() => undefined}
218 onAddTerminalContext={() => undefined}224 onAddTerminalContext={() => undefined}
219 focusRequestId={0}225 focusRequestId={0}
@@ -259,6 +265,47 @@ describe("TerminalViewport", () => { · 39 unmodified lines
259 }265 }
260 });266 });
261 267
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
262 it("reattaches the terminal when the scoped thread reference changes", async () => {309 it("reattaches the terminal when the scoped thread reference changes", async () => {
263 const environmentA = createEnvironmentApi();310 const environmentA = createEnvironmentApi();
264 const environmentB = createEnvironmentApi();311 const environmentB = createEnvironmentApi();
@@ -313,6 +360,34 @@ describe("TerminalViewport", () => { · 48 unmodified lines
313 }360 }
314 });361 });
315 362
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
316 it("uses the drawer surface colors for the terminal theme", async () => {391 it("uses the drawer surface colors for the terminal theme", async () => {
317 const environment = createEnvironmentApi();392 const environment = createEnvironmentApi();
318 environmentApiById.set("environment-a", environment);393 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
75 }75 }
76}76}
77 77
78function fitTerminalSafely(fitAddon: FitAddon): boolean {
79 try {
80 fitAddon.fit();
81 return true;
82 } catch {
83 return false;
84 }
85}
86
87function 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
78function normalizeComputedColor(value: string | null | undefined, fallback: string): string {96function normalizeComputedColor(value: string | null | undefined, fallback: string): string {
79 const normalizedValue = value?.trim().toLowerCase();97 const normalizedValue = value?.trim().toLowerCase();
80 if (98 if (
@@ -283,6 +301,7 @@ export function TerminalViewport({ · 202 unmodified lines
283 const selectionActionOpenRef = useRef(false);301 const selectionActionOpenRef = useRef(false);
284 const selectionActionTimerRef = useRef<number | null>(null);302 const selectionActionTimerRef = useRef<number | null>(null);
285 const keybindingsRef = useRef(keybindings);303 const keybindingsRef = useRef(keybindings);
304 const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]);
286 const handleSessionExited = useEffectEvent(() => {305 const handleSessionExited = useEffectEvent(() => {
287 onSessionExited();306 onSessionExited();
288 });307 });
@@ -302,7 +321,7 @@ export function TerminalViewport({ · 13 unmodified lines
302 let disposed = false;321 let disposed = false;
303 const api = readEnvironmentApi(environmentId);322 const api = readEnvironmentApi(environmentId);
304 const localApi = readLocalApi();323 const localApi = readLocalApi();
305 if (!api || !localApi) return;324 if (!api) return;
306 325
307 const fitAddon = new FitAddon();326 const fitAddon = new FitAddon();
308 const terminal = new Terminal({327 const terminal = new Terminal({
@@ -315,7 +334,7 @@ export function TerminalViewport({ · 6 unmodified lines
315 });334 });
316 terminal.loadAddon(fitAddon);335 terminal.loadAddon(fitAddon);
317 terminal.open(mount);336 terminal.open(mount);
318 fitAddon.fit();337 fitTerminalSafely(fitAddon);
319 338
320 terminalRef.current = terminal;339 terminalRef.current = terminal;
321 fitAddonRef.current = fitAddon;340 fitAddonRef.current = fitAddon;
@@ -369,6 +388,10 @@ export function TerminalViewport({ · 47 unmodified lines
369 };388 };
370 389
371 const showSelectionAction = async () => {390 const showSelectionAction = async () => {
391 if (!localApi) {
392 clearSelectionAction();
393 return;
394 }
372 if (selectionActionOpenRef.current) {395 if (selectionActionOpenRef.current) {
373 return;396 return;
374 }397 }
@@ -479,6 +502,10 @@ export function TerminalViewport({ · 104 unmodified lines
479 502
480 const latestTerminal = terminalRef.current;503 const latestTerminal = terminalRef.current;
481 if (!latestTerminal) return;504 if (!latestTerminal) return;
505 if (!localApi) {
506 writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser.");
507 return;
508 }
482 509
483 if (match.kind === "url") {510 if (match.kind === "url") {
484 void localApi.shell.openExternal(match.text).catch((error: unknown) => {511 void localApi.shell.openExternal(match.text).catch((error: unknown) => {
@@ -630,7 +657,7 @@ export function TerminalViewport({ · 145 unmodified lines
630 const activeTerminal = terminalRef.current;657 const activeTerminal = terminalRef.current;
631 const activeFitAddon = fitAddonRef.current;658 const activeFitAddon = fitAddonRef.current;
632 if (!activeTerminal || !activeFitAddon) return;659 if (!activeTerminal || !activeFitAddon) return;
633 activeFitAddon.fit();660 fitTerminalSafely(activeFitAddon);
634 unsubscribeAttach = attachTerminalSession({661 unsubscribeAttach = attachTerminalSession({
635 environmentId,662 environmentId,
636 client: api,663 client: api,
@@ -664,7 +691,7 @@ export function TerminalViewport({ · 27 unmodified lines
664 if (!activeTerminal || !activeFitAddon) return;691 if (!activeTerminal || !activeFitAddon) return;
665 const wasAtBottom =692 const wasAtBottom =
666 activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY;693 activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY;
667 activeFitAddon.fit();694 fitTerminalSafely(activeFitAddon);
668 if (wasAtBottom) {695 if (wasAtBottom) {
669 activeTerminal.scrollToBottom();696 activeTerminal.scrollToBottom();
670 }697 }
@@ -700,7 +727,7 @@ export function TerminalViewport({ · 29 unmodified lines
700 // autoFocus is intentionally omitted;727 // autoFocus is intentionally omitted;
701 // it is only read at mount time and must not trigger terminal teardown/recreation.728 // it is only read at mount time and must not trigger terminal teardown/recreation.
702 // eslint-disable-next-line react-hooks/exhaustive-deps729 // eslint-disable-next-line react-hooks/exhaustive-deps
703 }, [cwd, environmentId, runtimeEnv, terminalId, threadId, worktreePath]);730 }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);
704 731
705 useEffect(() => {732 useEffect(() => {
706 if (!autoFocus) return;733 if (!autoFocus) return;
@@ -721,7 +748,7 @@ export function TerminalViewport({ · 14 unmodified lines
721 if (!api || !terminal || !fitAddon) return;748 if (!api || !terminal || !fitAddon) return;
722 const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;749 const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;
723 const frame = window.requestAnimationFrame(() => {750 const frame = window.requestAnimationFrame(() => {
724 fitAddon.fit();751 fitTerminalSafely(fitAddon);
725 if (wasAtBottom) {752 if (wasAtBottom) {
726 terminal.scrollToBottom();753 terminal.scrollToBottom();
727 }754 }
apps/web/src/lib/contextWindow.test.ts+17−0
@@ -44,6 +44,23 @@ describe("contextWindow", () => { · 43 unmodified lines
44 expect(snapshot).toBeNull();44 expect(snapshot).toBeNull();
45 });45 });
46 46
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
47 it("formats compact token counts", () => {64 it("formats compact token counts", () => {
48 expect(formatContextWindowTokens(999)).toBe("999");65 expect(formatContextWindowTokens(999)).toBe("999");
49 expect(formatContextWindowTokens(1400)).toBe("1.4k");66 expect(formatContextWindowTokens(1400)).toBe("1.4k");
apps/web/src/lib/contextWindow.ts+1−1
@@ -36,7 +36,7 @@ export function deriveLatestContextWindowSnapshot( · 35 unmodified lines
36 36
37 const payload = asRecord(activity.payload);37 const payload = asRecord(activity.payload);
38 const usedTokens = asFiniteNumber(payload?.usedTokens);38 const usedTokens = asFiniteNumber(payload?.usedTokens);
39 if (usedTokens === null || usedTokens <= 0) {39 if (usedTokens === null || usedTokens < 0) {
40 continue;40 continue;
41 }41 }
42 42
apps/web/src/lib/lruCache.test.ts+9−0
@@ -40,4 +40,13 @@ describe("LRUCache", () => { · 39 unmodified lines
40 expect(cache.get("b")).toBe("B");40 expect(cache.get("b")).toBe("B");
41 expect(cache.get("c")).toBe("C");41 expect(cache.get("c")).toBe("C");
42 });42 });
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 });
43});52});
apps/web/src/lib/lruCache.ts+4−0
@@ -29,6 +29,10 @@ export class LRUCache<T> { · 28 unmodified lines
29 this.cache.delete(key);29 this.cache.delete(key);
30 }30 }
31 31
32 if (approximateSize > this.maxMemoryBytes) {
33 return;
34 }
35
32 this.evictIfNeeded(approximateSize);36 this.evictIfNeeded(approximateSize);
33 this.cache.set(key, { value, approximateSize });37 this.cache.set(key, { value, approximateSize });
34 this.totalSize += approximateSize;38 this.totalSize += approximateSize;
apps/web/src/lib/threadSort.test.ts+48−0
@@ -114,6 +114,31 @@ describe("sortThreads", () => { · 113 unmodified lines
114 ]);114 ]);
115 });115 });
116 116
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
117 it("falls back to id ordering when threads have no sortable timestamps", () => {142 it("falls back to id ordering when threads have no sortable timestamps", () => {
118 const sorted = sortThreads(143 const sorted = sortThreads(
119 [144 [
@@ -162,6 +187,29 @@ describe("sortThreads", () => { · 42 unmodified lines
162 ]);187 ]);
163 });188 });
164 189
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
165 it("returns the latest active thread for a project", () => {213 it("returns the latest active thread for a project", () => {
166 const latestThread = getLatestThreadForProject(214 const latestThread = getLatestThreadForProject(
167 [215 [
apps/web/src/lib/threadSort.ts+15−2
@@ -13,6 +13,17 @@ export function toSortableTimestamp(iso: string | undefined): number | null { · 12 unmodified lines
13 return Number.isFinite(ms) ? ms : null;13 return Number.isFinite(ms) ? ms : null;
14}14}
15 15
16function 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
16function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {27function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
17 if (thread.latestUserMessageAt) {28 if (thread.latestUserMessageAt) {
18 return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;29 return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;
@@ -34,7 +45,7 @@ function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { · 15 unmodified lines
34 return latestUserMessageTimestamp;45 return latestUserMessageTimestamp;
35 }46 }
36 47
37 return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY;48 return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY;
38}49}
39 50
40export function getThreadSortTimestamp(51export function getThreadSortTimestamp(
@@ -42,7 +53,9 @@ export function getThreadSortTimestamp( · 1 unmodified line
42 sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,53 sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,
43): number {54): number {
44 if (sortOrder === "created_at") {55 if (sortOrder === "created_at") {
45 return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY;56 return (
57 getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY
58 );
46 }59 }
47 return getLatestUserMessageTimestamp(thread);60 return getLatestUserMessageTimestamp(thread);
48}61}