- 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 | --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); |
| @@ -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) in | 40 | 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 | } |
| @@ -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 | ||
| 21 | private struct ReviewDiffNativeWordDiffRange: Decodable { | 24 | private 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 in | 345 | 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 in | 354 | 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 = false | 791 | 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.rowHeight | 819 | return style.rowHeight |
| 784 | } | 820 | } |
| 785 | 821 | ||
| @@ -871,12 +907,22 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 85 unmodified lines | |||
| 871 | return gesture | 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 | override init(frame: CGRect) { | 918 | override init(frame: CGRect) { |
| 875 | super.init(frame: frame) | 919 | super.init(frame: frame) |
| 876 | isOpaque = true | 920 | isOpaque = true |
| 877 | contentMode = .redraw | 921 | 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 = .redraw | 939 | 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 | return | 996 | 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: CGRect | 1681 | 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 / 2 | 2089 | rect.midY - font.lineHeight / 2 |
| 1896 | } | 2090 | } |
| @@ -11,7 +11,7 @@ interface ExpoGlobalWithViewConfig { · 10 unmodified lines | |||
| 11 | } | 11 | } |
| 12 | 12 | ||
| 13 | export interface NativeReviewDiffRow { | 13 | export 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 | ||
| 36 | export interface NativeReviewDiffWordDiffRange { | 39 | export 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 | ||
| 113 | let cachedNativeReviewDiffView: ComponentType<NativeReviewDiffViewProps> | undefined; | 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 | import type { ReviewRenderableLineRow } from "./reviewModel"; | 3 | import type { ReviewRenderableLineRow } from "./reviewModel"; |
| 4 | 4 | ||
| 5 | export interface ReviewCommentTarget { | 5 | export 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 | ||
| 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 | let currentTarget: ReviewCommentTarget | null = null; | 25 | let currentTarget: ReviewCommentTarget | null = null; |
| 14 | const listeners = new Set<() => void>(); | 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 | function emitChange() { | 30 | function 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 | ||
| 75 | export function buildReviewCommentTarget( | 89 | export 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 | ||
| 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 | export function formatReviewCommentContext(target: ReviewCommentTarget, comment: string): string { | 192 | export 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 | ||
| 156 | export function countReviewCommentContexts(value: string): number { | 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 | 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 | <Markdown | 380 | <Markdown |
| 350 | options={{ gfm: true }} | 381 | options={{ gfm: true }} |
| @@ -372,14 +403,14 @@ function renderFeedEntry( · 21 unmodified lines | |||
| 372 | > | 403 | > |
| 373 | <Image | 404 | <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} image | 480 | {entry.queuedMessage.attachments.length} image |
| 447 | {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached | 481 | {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 | initialScrollAtEnd | 691 | 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} |
| @@ -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 | <TerminalViewport | 216 | <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); |
| @@ -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 | function normalizeComputedColor(value: string | null | undefined, fallback: string): string { | 96 | function 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-deps | 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 | 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 | } |
| @@ -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"); |
| @@ -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 | ||
| @@ -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 | }); |
| @@ -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; |
| @@ -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 | [ |
| @@ -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 | ||
| 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 | function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { | 27 | function 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 | ||
| 40 | export function getThreadSortTimestamp( | 51 | export 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 | } |