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