diff --git a/apps/bridge/lib/inspector/flutter_inspector_client.dart b/apps/bridge/lib/inspector/flutter_inspector_client.dart index 4ddb872..25ed2ec 100644 --- a/apps/bridge/lib/inspector/flutter_inspector_client.dart +++ b/apps/bridge/lib/inspector/flutter_inspector_client.dart @@ -125,9 +125,16 @@ class VmServiceFlutterInspectorClient implements FlutterInspectorClient { ); } + final boundsById = await _fetchWidgetBoundsById( + vmService, + isolateId: isolateId, + diagnosticsNode: result, + ); + return WidgetTreeNode.fromFlutterDiagnostics( result, projectRoot: session.projectRoot, + boundsById: boundsById, ); } finally { await vmService.dispose(); @@ -303,6 +310,123 @@ class VmServiceFlutterInspectorClient implements FlutterInspectorClient { } } +Future> _fetchWidgetBoundsById( + FlutterInspectorVmService vmService, { + required String isolateId, + required Map diagnosticsNode, +}) async { + final boundsById = {}; + + for (final widgetId in _widgetIdsInDiagnostics(diagnosticsNode)) { + final WidgetBounds? bounds; + try { + bounds = await _fetchWidgetBounds( + vmService, + isolateId: isolateId, + widgetId: widgetId, + ); + } on _WidgetBoundsExtensionUnavailable { + break; + } + if (bounds == null) { + continue; + } + boundsById[widgetId] = bounds; + } + + return boundsById; +} + +Iterable _widgetIdsInDiagnostics( + Map diagnosticsNode, +) sync* { + final id = diagnosticsNode['valueId']?.toString().trim(); + if (id != null && id.isNotEmpty) { + yield id; + } + + final rawChildren = diagnosticsNode['children']; + if (rawChildren is! List) { + return; + } + + for (final child in rawChildren.whereType>()) { + yield* _widgetIdsInDiagnostics(child); + } +} + +Future _fetchWidgetBounds( + FlutterInspectorVmService vmService, { + required String isolateId, + required String widgetId, +}) async { + final response = await _callWidgetBoundsExtension( + vmService, + isolateId: isolateId, + widgetId: widgetId, + ); + if (response == null) { + return null; + } + + final result = _decodeInspectorResult(response); + if (result is! Map) { + return null; + } + + return _widgetBoundsFromAppSideResult(result); +} + +Future?> _callWidgetBoundsExtension( + FlutterInspectorVmService vmService, { + required String isolateId, + required String widgetId, +}) async { + try { + return await vmService.callServiceExtension( + 'ext.ask_ui.widgetBounds', + isolateId: isolateId, + args: { + 'id': widgetId, + 'groupName': 'ask_ui_widget_tree', + }, + ); + } on FlutterInspectorServiceUnavailableException { + throw const _WidgetBoundsExtensionUnavailable(); + } on RPCError catch (error) { + if (error.code == RPCErrorKind.kMethodNotFound.code) { + throw const _WidgetBoundsExtensionUnavailable(); + } + rethrow; + } +} + +WidgetBounds? _widgetBoundsFromAppSideResult(Map result) { + final x = _doubleFrom(result['x']); + final y = _doubleFrom(result['y']); + final width = _doubleFrom(result['width']); + final height = _doubleFrom(result['height']); + final devicePixelRatio = _doubleFrom(result['devicePixelRatio']) ?? 1; + + if (x == null || y == null || width == null || height == null) { + return null; + } + if (width <= 0 || height <= 0 || devicePixelRatio <= 0) { + return null; + } + + return WidgetBounds( + x: x * devicePixelRatio, + y: y * devicePixelRatio, + width: width * devicePixelRatio, + height: height * devicePixelRatio, + ); +} + +class _WidgetBoundsExtensionUnavailable implements Exception { + const _WidgetBoundsExtensionUnavailable(); +} + Object? _decodeInspectorResult(Map response) { final result = response['result'] ?? response['object'] ?? response['value']; @@ -313,6 +437,18 @@ Object? _decodeInspectorResult(Map response) { return result ?? response; } +double? _doubleFrom(Object? value) { + if (value is num) { + return value.toDouble(); + } + + final stringValue = value?.toString().trim(); + if (stringValue == null || stringValue.isEmpty) { + return null; + } + return double.tryParse(stringValue); +} + class FlutterInspectorException implements Exception { const FlutterInspectorException(this.message); diff --git a/apps/bridge/lib/widget_tree/widget_tree_snapshot.dart b/apps/bridge/lib/widget_tree/widget_tree_snapshot.dart index 3b9b5cc..7473e97 100644 --- a/apps/bridge/lib/widget_tree/widget_tree_snapshot.dart +++ b/apps/bridge/lib/widget_tree/widget_tree_snapshot.dart @@ -14,6 +14,7 @@ class WidgetTreeNode { required this.id, required this.label, required this.children, + this.bounds, this.sourceLocation, this.visibleText, this.semanticInfo, @@ -42,8 +43,10 @@ class WidgetTreeNode { factory WidgetTreeNode.fromFlutterDiagnostics( Map diagnosticsNode, { String? projectRoot, + Map boundsById = const {}, }) { final rawChildren = diagnosticsNode['children']; + final id = diagnosticsNode['valueId']?.toString() ?? ''; final children = rawChildren is List ? rawChildren .whereType>() @@ -51,6 +54,7 @@ class WidgetTreeNode { (child) => WidgetTreeNode.fromFlutterDiagnostics( child, projectRoot: projectRoot, + boundsById: boundsById, ), ) .toList() @@ -58,7 +62,7 @@ class WidgetTreeNode { final description = diagnosticsNode['description']?.toString() ?? ''; return WidgetTreeNode( - id: diagnosticsNode['valueId']?.toString() ?? '', + id: id, label: description, sourceLocation: _sourceLocationFromDiagnostics( diagnosticsNode, @@ -70,12 +74,14 @@ class WidgetTreeNode { 'semanticLabel', 'semanticDescription', ]), + bounds: boundsById[id], children: children, ); } final String id; final String label; + final WidgetBounds? bounds; final String? sourceLocation; final String? visibleText; final String? semanticInfo; @@ -85,6 +91,7 @@ class WidgetTreeNode { return { 'id': id, 'label': label, + if (bounds != null) 'bounds': bounds!.toJson(), if (sourceLocation != null) 'sourceLocation': sourceLocation, if (visibleText != null) 'visibleText': visibleText, if (semanticInfo != null) 'semanticInfo': semanticInfo, @@ -93,6 +100,30 @@ class WidgetTreeNode { } } +/// Visual rectangle for a Flutter widget in device-screen coordinates. +class WidgetBounds { + const WidgetBounds({ + required this.x, + required this.y, + required this.width, + required this.height, + }); + + final double x; + final double y; + final double width; + final double height; + + Map toJson() { + return { + 'x': x, + 'y': y, + 'width': width, + 'height': height, + }; + } +} + String? _sourceLocationFromDiagnostics( Map diagnosticsNode, { required String? projectRoot, diff --git a/apps/bridge/test/chat/chat_ingress_test.dart b/apps/bridge/test/chat/chat_ingress_test.dart index 7d8c209..b6d8d4e 100644 --- a/apps/bridge/test/chat/chat_ingress_test.dart +++ b/apps/bridge/test/chat/chat_ingress_test.dart @@ -206,6 +206,42 @@ void main() { (unowned as RejectedChatIngressMessage).error, 'invalid_chat_parts'); expect(owned, isA()); }); + + test('does not restore snapshot ownership in a restarted Bridge Session', + () { + const snapshotPath = '/tmp/ask-ui/session-1/snapshots/comment.png'; + existingSnapshotPaths.add(snapshotPath); + session.manageLocalPath('/tmp/ask-ui/session-1'); + final originalSessionResult = ingress.parseMessage( + { + 'parts': [ + selectionCommentPart(snapshotPath), + ], + }, + session, + ); + final restartedSession = BridgeSession( + id: 'session-1', + vmServiceUri: session.vmServiceUri, + projectRoot: session.projectRoot, + deviceId: session.deviceId, + ); + + final restartedSessionResult = ingress.parseMessage( + { + 'parts': [ + selectionCommentPart(snapshotPath), + ], + }, + restartedSession, + ); + + expect(originalSessionResult, isA()); + expect( + (restartedSessionResult as RejectedChatIngressMessage).error, + 'invalid_chat_parts', + ); + }); }); } diff --git a/apps/bridge/test/flutter_inspector_client_test.dart b/apps/bridge/test/flutter_inspector_client_test.dart index 395e060..6538a0f 100644 --- a/apps/bridge/test/flutter_inspector_client_test.dart +++ b/apps/bridge/test/flutter_inspector_client_test.dart @@ -68,6 +68,14 @@ void main() { 'fullDetails': 'true', }, ), + const FlutterInspectorVmServiceCall( + method: 'ext.ask_ui.widgetBounds', + isolateId: 'isolates/main', + args: { + 'id': 'inspector-1', + 'groupName': 'ask_ui_widget_tree', + }, + ), ]); }); @@ -96,6 +104,165 @@ void main() { }); }); + test('enriches widget tree nodes with app-side widget bounds', () async { + final vmService = RecordingFlutterInspectorVmService( + { + 'result': { + 'valueId': 'inspector-1', + 'description': 'MaterialApp', + 'children': [ + { + 'valueId': 'inspector-2', + 'description': 'FilledButton', + }, + ], + }, + }, + serviceExtensionResponses: { + 'ext.ask_ui.widgetBounds': [ + { + 'result': { + 'x': 4, + 'y': 8, + 'width': 120, + 'height': 48, + 'devicePixelRatio': 2.5, + }, + }, + { + 'result': + '{"x":24,"y":36,"width":80,"height":20,"devicePixelRatio":2.5}', + }, + ], + }, + ); + final client = VmServiceFlutterInspectorClient( + vmServiceFactory: RecordingFlutterInspectorVmServiceFactory(vmService), + ); + + final root = await client.fetchRootWidgetTree( + BridgeSession( + id: 'session-1', + vmServiceUri: 'ws://127.0.0.1:12345/ws', + projectRoot: '/Users/example/app', + deviceId: '19271FDF6007TY', + ), + ); + + expect(root.toJson(), { + 'id': 'inspector-1', + 'label': 'MaterialApp', + 'bounds': { + 'x': 10.0, + 'y': 20.0, + 'width': 300.0, + 'height': 120.0, + }, + 'children': [ + { + 'id': 'inspector-2', + 'label': 'FilledButton', + 'bounds': { + 'x': 60.0, + 'y': 90.0, + 'width': 200.0, + 'height': 50.0, + }, + 'children': [], + }, + ], + }); + expect(vmService.calls, [ + const FlutterInspectorVmServiceCall( + method: 'ext.flutter.inspector.setPubRootDirectories', + isolateId: 'isolates/main', + args: {'arg0': '/Users/example/app'}, + ), + const FlutterInspectorVmServiceCall( + method: 'ext.flutter.inspector.getRootWidgetTree', + isolateId: 'isolates/main', + args: { + 'groupName': 'ask_ui_widget_tree', + 'isSummaryTree': 'true', + 'withPreviews': 'true', + 'fullDetails': 'true', + }, + ), + const FlutterInspectorVmServiceCall( + method: 'ext.ask_ui.widgetBounds', + isolateId: 'isolates/main', + args: { + 'id': 'inspector-1', + 'groupName': 'ask_ui_widget_tree', + }, + ), + const FlutterInspectorVmServiceCall( + method: 'ext.ask_ui.widgetBounds', + isolateId: 'isolates/main', + args: { + 'id': 'inspector-2', + 'groupName': 'ask_ui_widget_tree', + }, + ), + ]); + }); + + test('keeps widget tree available when app-side bounds extension is absent', + () async { + final vmService = RecordingFlutterInspectorVmService({ + 'result': { + 'valueId': 'inspector-1', + 'description': 'MaterialApp', + 'children': [ + { + 'valueId': 'inspector-2', + 'description': 'Scaffold', + }, + ], + }, + }, unavailableServiceExtensions: { + 'ext.ask_ui.widgetBounds' + }); + final client = VmServiceFlutterInspectorClient( + vmServiceFactory: RecordingFlutterInspectorVmServiceFactory(vmService), + ); + + final root = await client.fetchRootWidgetTree( + BridgeSession( + id: 'session-1', + vmServiceUri: 'ws://127.0.0.1:12345/ws', + projectRoot: '/Users/example/app', + deviceId: '19271FDF6007TY', + ), + ); + + expect(root.toJson(), { + 'id': 'inspector-1', + 'label': 'MaterialApp', + 'children': [ + { + 'id': 'inspector-2', + 'label': 'Scaffold', + 'children': [], + }, + ], + }); + expect( + vmService.calls + .where((call) => call.method == 'ext.ask_ui.widgetBounds'), + [ + const FlutterInspectorVmServiceCall( + method: 'ext.ask_ui.widgetBounds', + isolateId: 'isolates/main', + args: { + 'id': 'inspector-1', + 'groupName': 'ask_ui_widget_tree', + }, + ), + ], + ); + }); + test('sets Flutter Inspector select widget mode through inspector.show', () async { final vmService = RecordingFlutterInspectorVmService({'result': null}); @@ -239,9 +406,20 @@ class RecordingFlutterInspectorVmServiceFactory } class RecordingFlutterInspectorVmService implements FlutterInspectorVmService { - RecordingFlutterInspectorVmService(this.widgetTreeResponse); + RecordingFlutterInspectorVmService( + this.widgetTreeResponse, { + Map>> serviceExtensionResponses = + const {}, + Set unavailableServiceExtensions = const {}, + }) : _serviceExtensionResponses = { + for (final entry in serviceExtensionResponses.entries) + entry.key: List>.of(entry.value), + }, + _unavailableServiceExtensions = unavailableServiceExtensions; final Map widgetTreeResponse; + final Map>> _serviceExtensionResponses; + final Set _unavailableServiceExtensions; final calls = []; final serviceExtensionStateListeners = []; @@ -269,6 +447,20 @@ class RecordingFlutterInspectorVmService implements FlutterInspectorVmService { if (method == 'ext.flutter.inspector.getRootWidgetTree') { return widgetTreeResponse; } + if (_unavailableServiceExtensions.contains(method)) { + throw const FlutterInspectorServiceUnavailableException( + 'Service extension is not registered.', + ); + } + final responses = _serviceExtensionResponses[method]; + if (responses != null && responses.isNotEmpty) { + return responses.removeAt(0); + } + if (method == 'ext.ask_ui.widgetBounds') { + throw const FlutterInspectorServiceUnavailableException( + 'Service extension is not registered.', + ); + } return {'result': null}; } diff --git a/apps/bridge/test/widget_tree_snapshot_test.dart b/apps/bridge/test/widget_tree_snapshot_test.dart index 65ddd33..93eaf24 100644 --- a/apps/bridge/test/widget_tree_snapshot_test.dart +++ b/apps/bridge/test/widget_tree_snapshot_test.dart @@ -78,5 +78,37 @@ void main() { }, ); }); + + test('normalizes app-side visual bounds by widget id', () { + final root = WidgetTreeNode.fromFlutterDiagnostics( + { + 'valueId': 'inspector-1', + 'description': 'Text', + }, + boundsById: const { + 'inspector-1': WidgetBounds( + x: 10.5, + y: 20, + width: 100, + height: 40, + ), + }, + ); + + expect( + root.toJson(), + { + 'id': 'inspector-1', + 'label': 'Text', + 'bounds': { + 'x': 10.5, + 'y': 20.0, + 'width': 100.0, + 'height': 40.0, + }, + 'children': [], + }, + ); + }); }); } diff --git a/apps/web/src/app/AskUiWorkbench.tsx b/apps/web/src/app/AskUiWorkbench.tsx index d9c7b45..4b63ec5 100644 --- a/apps/web/src/app/AskUiWorkbench.tsx +++ b/apps/web/src/app/AskUiWorkbench.tsx @@ -88,6 +88,7 @@ export function AskUiWorkbench() { activeSelectionCommentId={selectionComments.activeSelectionCommentId} attachmentTokens={selectionComments.attachmentTokens} chatSessionState={chatSession} + isReadOnly={isReadOnly} isSelectWidgetActive={actions.topBarActionState.isSelectWidgetActive} onAttachmentTokenClick={selectionComments.handleAttachmentTokenClick} onSelectionCommentStateChange={ diff --git a/apps/web/src/app/useWorkbenchSelectionComments.ts b/apps/web/src/app/useWorkbenchSelectionComments.ts index 18e35cf..904b845 100644 --- a/apps/web/src/app/useWorkbenchSelectionComments.ts +++ b/apps/web/src/app/useWorkbenchSelectionComments.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState } from 'react'; import { getInitialSelectionCommentState, + getLocatableWidgetBoundsById, getLocatableWidgetIds, getSelectedWidgetTarget, getSelectionCommentAttachmentTokens, @@ -45,6 +46,14 @@ export function useWorkbenchSelectionComments({ return getLocatableWidgetIds(widgetTreeState.root); }, [widgetTreeState]); + const locatableWidgetBoundsById = useMemo(() => { + if (widgetTreeState.status !== 'loaded') { + return new Map(); + } + + return getLocatableWidgetBoundsById(widgetTreeState.root); + }, [widgetTreeState]); + const attachmentTokens = useMemo( () => getSelectionCommentAttachmentTokens( @@ -58,10 +67,10 @@ export function useWorkbenchSelectionComments({ () => getSelectionCommentOverlayMarkers({ isSelectWidgetActive, - locatableWidgetIds, + locatableWidgetBoundsById, state: selectionCommentState, }), - [isSelectWidgetActive, locatableWidgetIds, selectionCommentState], + [isSelectWidgetActive, locatableWidgetBoundsById, selectionCommentState], ); const handleSelectedWidgetIdChange = useCallback((widgetId: string | null) => { diff --git a/apps/web/src/chat/chatComposerState.test.ts b/apps/web/src/chat/chatComposerState.test.ts index 391161f..b3f6581 100644 --- a/apps/web/src/chat/chatComposerState.test.ts +++ b/apps/web/src/chat/chatComposerState.test.ts @@ -4,10 +4,14 @@ import test from 'node:test'; import { CHAT_COMPOSER_TEXT_LIMIT, getChatComposerState, + getChatComposerTextareaInputPolicy, getComposerTextAfterSendResult, shouldSubmitChatComposerKey, } from './chatComposerState.ts'; -import { getInitialChatSessionState } from './chatSessionState.ts'; +import { + getInitialChatSessionState, + reduceChatSessionDisconnected, +} from './chatSessionState.ts'; test('enables Chat send only while the Agent is ready with text or attachments', () => { const state = getInitialChatSessionState({ @@ -59,6 +63,58 @@ test('disables Chat send when Agent Status is not ready', () => { }); }); +test('disables Chat send after session events disconnect', () => { + const state = reduceChatSessionDisconnected( + getInitialChatSessionState({ + status: 'ok', + agentStatus: 'agent_ready', + readOnly: false, + messages: [], + }), + ); + + assert.deepEqual(getChatComposerState(state, 'Make it primary.'), { + canSend: false, + disabledReason: 'Agent Status is Waiting for agent.', + isTooLong: false, + }); +}); + +test('enables prepared composer text after Agent ready recovery without clearing it', () => { + const disconnected = reduceChatSessionDisconnected( + getInitialChatSessionState({ + status: 'ok', + agentStatus: 'agent_working', + readOnly: false, + messages: [], + }), + ); + const preparedText = 'Make it primary.'; + + assert.deepEqual(getChatComposerState(disconnected, preparedText), { + canSend: false, + disabledReason: 'Agent Status is Waiting for agent.', + isTooLong: false, + }); + + const recovered = getInitialChatSessionState({ + status: 'ok', + agentStatus: 'agent_ready', + readOnly: false, + messages: [], + }); + + assert.deepEqual(getChatComposerState(recovered, preparedText), { + canSend: true, + disabledReason: null, + isTooLong: false, + }); + assert.equal( + getComposerTextAfterSendResult(preparedText, false), + preparedText, + ); +}); + test('limits typed composer text to 4000 characters', () => { const state = getInitialChatSessionState({ status: 'ok', @@ -74,6 +130,12 @@ test('limits typed composer text to 4000 characters', () => { }); }); +test('lets typed composer text exceed the limit so inline validation can explain it', () => { + assert.deepEqual(getChatComposerTextareaInputPolicy(), { + maxLength: undefined, + }); +}); + test('Enter submits the Chat composer while Shift+Enter inserts a newline', () => { assert.equal(shouldSubmitChatComposerKey('Enter', false), true); assert.equal(shouldSubmitChatComposerKey('Enter', true), false); diff --git a/apps/web/src/chat/chatComposerState.ts b/apps/web/src/chat/chatComposerState.ts index 5947856..d0f24b7 100644 --- a/apps/web/src/chat/chatComposerState.ts +++ b/apps/web/src/chat/chatComposerState.ts @@ -11,6 +11,10 @@ export type ChatComposerState = { isTooLong: boolean; }; +export type ChatComposerTextareaInputPolicy = { + maxLength: number | undefined; +}; + /** * Derive Chat composer sendability from Chat session state and attachments. * @@ -83,6 +87,18 @@ export function getChatComposerState( }; } +/** + * Return DOM input constraints for the Chat composer textarea. + * + * The composer intentionally does not set `maxLength`: over-limit text must + * remain in local state so inline validation can explain why Send is disabled. + */ +export function getChatComposerTextareaInputPolicy(): ChatComposerTextareaInputPolicy { + return { + maxLength: undefined, + }; +} + /** * Return whether a textarea key press should submit Chat. * diff --git a/apps/web/src/chat/chatSessionState.test.ts b/apps/web/src/chat/chatSessionState.test.ts index b0c0c4a..19745fa 100644 --- a/apps/web/src/chat/chatSessionState.test.ts +++ b/apps/web/src/chat/chatSessionState.test.ts @@ -80,6 +80,44 @@ test('applies Chat History and Agent Status bridge events', () => { ]); }); +test('keeps read-only Chat sessions observable while applying bridge events', () => { + const initial = getInitialChatSessionState({ + status: 'ok', + agentStatus: 'waiting_for_agent', + readOnly: true, + messages: [], + }); + + const nextState = reduceChatSessionBridgeEvent(initial, { + type: 'chat_snapshot', + sessionId: 'session-1', + payload: { + agentStatus: 'agent_ready', + messages: [ + { + id: 'message-1', + role: 'agent', + text: 'Ready.', + }, + ], + }, + }); + + assert.deepEqual(nextState, { + status: 'ready', + agentStatus: 'agent_ready', + readOnly: true, + connectionWarning: null, + messages: [ + { + id: 'message-1', + role: 'agent', + text: 'Ready.', + }, + ], + }); +}); + test('replays queued bridge events after loading the initial Chat snapshot', () => { assert.deepEqual( getInitialChatSessionStateWithQueuedEvents( @@ -151,6 +189,45 @@ test('maps session event disconnect to Waiting for agent with a warning', () => }); }); +test('clears the session event disconnect warning when bridge events resume', () => { + const disconnected = reduceChatSessionDisconnected( + getInitialChatSessionState({ + status: 'ok', + agentStatus: 'agent_working', + readOnly: false, + messages: [ + { + id: 'message-1', + role: 'user', + text: 'Make it primary.', + }, + ], + }), + ); + + const reconnected = reduceChatSessionBridgeEvent(disconnected, { + type: 'agent_status_changed', + sessionId: 'session-1', + payload: { + agentStatus: 'agent_ready', + }, + }); + + assert.deepEqual(reconnected, { + status: 'ready', + agentStatus: 'agent_ready', + readOnly: false, + connectionWarning: null, + messages: [ + { + id: 'message-1', + role: 'user', + text: 'Make it primary.', + }, + ], + }); +}); + test('adds a temporary Agent working placeholder to visible Chat History', () => { const state = getInitialChatSessionState({ status: 'ok', diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 57c27a2..053149f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,4 +1,4 @@ -import { CHAT_COMPOSER_TEXT_LIMIT } from '../../chat/chatComposerState'; +import { getChatComposerTextareaInputPolicy } from '../../chat/chatComposerState'; import type { ChatSessionState } from '../../chat/chatSessionState'; import type { SelectionCommentAttachmentToken } from '../../selection-comments/selectionCommentState'; import type { useChatComposerFlow } from './useChatComposerFlow'; @@ -20,6 +20,8 @@ export function ChatComposer({ placeholder: string; sessionId: string | null; }) { + const inputPolicy = getChatComposerTextareaInputPolicy(); + return (
{ composer.handleComposerTextChange(event.target.value); }} diff --git a/apps/web/src/components/chat/ChatPanel.tsx b/apps/web/src/components/chat/ChatPanel.tsx index f6cd1c9..bbbc01e 100644 --- a/apps/web/src/components/chat/ChatPanel.tsx +++ b/apps/web/src/components/chat/ChatPanel.tsx @@ -24,6 +24,7 @@ export function ChatPanel({ activeSelectionCommentId, attachmentTokens, chatSessionState, + isReadOnly, isSelectWidgetActive, onAttachmentTokenClick, onSelectionCommentStateChange, @@ -36,6 +37,7 @@ export function ChatPanel({ activeSelectionCommentId: string | null; attachmentTokens: SelectionCommentAttachmentToken[]; chatSessionState: ChatSessionState; + isReadOnly: boolean; isSelectWidgetActive: boolean; onAttachmentTokenClick: (token: SelectionCommentAttachmentToken) => void; onSelectionCommentStateChange: Dispatch>; @@ -49,6 +51,7 @@ export function ChatPanel({ const selectionComments = useSelectionCommentPanelFlow({ activeSelectionCommentId, attachmentTokens, + isReadOnly, isSelectWidgetActive, onSelectionCommentStateChange, selectedWidget, diff --git a/apps/web/src/components/chat/SelectedWidgetSection.tsx b/apps/web/src/components/chat/SelectedWidgetSection.tsx index 487ad0b..55ff43c 100644 --- a/apps/web/src/components/chat/SelectedWidgetSection.tsx +++ b/apps/web/src/components/chat/SelectedWidgetSection.tsx @@ -1,4 +1,4 @@ -import { SELECTION_COMMENT_TEXT_LIMIT } from '../../selection-comments/selectionCommentState'; +import { getSelectionCommentTextareaInputPolicy } from '../../selection-comments/selectionCommentState'; import type { useSelectionCommentPanelFlow } from './useSelectionCommentPanelFlow'; type SelectionCommentPanelFlow = ReturnType< @@ -15,6 +15,7 @@ export function SelectedWidgetSection({ title: string; }) { const panelTarget = selectionComments.panelTarget; + const inputPolicy = getSelectionCommentTextareaInputPolicy(); return (
@@ -61,7 +62,8 @@ export function SelectedWidgetSection({