Conversation
Copilot / Claude Opus 4.5 It said: Next Steps Install clasp: npm install -g @google/clasp Login: clasp login Create project: cd google-docs-addon && clasp create --type docs --title "Writing Tools" Push code: clasp push Test: Open Apps Script editor → Deploy → Test deployments → Install Configure backend URL in Script Properties The React app will need a webpack config for bundling into the sidebar, but the Apps Script functions can be tested immediately using the test button in sidebar.html.
There was a problem hiding this comment.
Pull request overview
Adds an initial Google Docs add-on implementation (“Writing Tools”) and supporting frontend build entrypoints to run the existing React UI inside a Docs sidebar.
Changes:
- Introduces an Apps Script add-on (
Code.gs,sidebar.html, manifest, clasp config/ignore) to show a sidebar and perform document operations + backend proxying. - Adds a Google Docs-specific webpack config and React entrypoint (
index-gdocs.tsx) plus an editor API adapter with selection polling. - Updates frontend API base URL/export surface and adds build/dev scripts + gitignore entries for new build outputs.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| google-docs-addon/sidebar.html | Sidebar bootstrap + Apps Script bridge + dev/prod loading strategy |
| google-docs-addon/appsscript.json | Apps Script manifest stub |
| google-docs-addon/README.md | Add-on architecture + setup/deployment docs |
| google-docs-addon/Code.gs | Apps Script menu/sidebar entrypoints, doc ops, backend proxy, user properties |
| google-docs-addon/.claspignore | Restricts clasp pushes to add-on files |
| google-docs-addon/.clasp.json | Clasp project linkage config |
| frontend/webpack.google-docs.config.js | Webpack build/dev-server for Docs bundle |
| frontend/src/index-gdocs.tsx | React entrypoint for Docs sidebar runtime |
| frontend/src/api/index.ts | API constants and platform/API exports |
| frontend/src/api/googleDocsEditorAPI.ts | Docs EditorAPI implementation + selection polling |
| frontend/package.json | Adds Docs build + dev-server scripts |
| .gitignore | Ignores new build output directories |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| allowedHosts: 'all', | ||
| headers: { | ||
| 'Access-Control-Allow-Origin': '*' | ||
| } |
There was a problem hiding this comment.
The devServer here is HTTP-only (port: 3001). When loaded inside the Google Docs sidebar (served over HTTPS), browsers commonly block HTTP subresources as mixed content. Consider enabling HTTPS for this dev server (similar to frontend/webpack.config.js) so the dev workflow works without insecure-content exceptions.
| } | |
| }, | |
| https: true |
| function selectPhrase(phrase) { | ||
| const doc = DocumentApp.getActiveDocument(); | ||
| const body = doc.getBody(); | ||
|
|
||
| const searchResult = body.findText(phrase); | ||
|
|
There was a problem hiding this comment.
selectPhrase passes phrase directly into body.findText(...), which treats the input as a regular expression. If the phrase includes regex metacharacters (e.g., ., ?, (), the search can behave incorrectly or error. Consider escaping the phrase to a literal regex before calling findText.
| ## Key Differences from Word Add-in | ||
|
|
||
| | Aspect | Word Add-in | Google Docs Add-on | | ||
| |--------|-------------|-------------------| | ||
| | Document API | Office.js (client-side) | DocumentApp (server-side via Apps Script) | | ||
| | UI Framework | HTML/JS in taskpane | HTML/JS in sidebar | | ||
| | API calls | Direct from browser | Proxied through Apps Script | | ||
| | Selection events | Native Office events | Polling (no native events) | |
There was a problem hiding this comment.
The README documents specific OAuth scopes in appsscript.json, but the manifest in this PR doesn’t declare oauthScopes at all. This can lead to unexpected authorization prompts/behavior and makes Marketplace deployment harder. Either add the documented scopes to the manifest or adjust the README section to match the actual manifest strategy.
| pollingInterval = setInterval(() => { | ||
| void pollForChanges(); | ||
| }, 1000); // Poll every second | ||
| } |
There was a problem hiding this comment.
Selection polling calls getDocContext() via Apps Script every 1s while any handler is registered. This can consume Apps Script quotas quickly (and adds latency/battery usage). Consider making the interval configurable, pausing when the sidebar is hidden/inactive, and/or using a backoff when repeated polling errors occur.
| plugins: [ | ||
| new MiniCssExtractPlugin({ | ||
| filename: '[name].css' | ||
| }), | ||
| new webpack.DefinePlugin({ | ||
| 'process.env.AUTH0_DOMAIN': JSON.stringify('dev-rbroo1fvav24wamu.us.auth0.com'), | ||
| 'process.env.AUTH0_CLIENT_ID': JSON.stringify('YZhokQZRgE2YUqU5Is9LcaMiCzujoaVr'), | ||
| 'process.env.NODE_ENV': JSON.stringify(options.mode || 'development') | ||
| }), | ||
| // Generate a standalone HTML file for testing | ||
| new HtmlWebpackPlugin({ | ||
| filename: 'sidebar-bundled.html', | ||
| template: path.resolve(__dirname, '../google-docs-addon/sidebar.html'), | ||
| inject: 'body', | ||
| scriptLoading: 'blocking' | ||
| }) |
There was a problem hiding this comment.
This config uses MiniCssExtractPlugin, which will emit a separate google-docs.css file. Apps Script sidebars generally can't serve arbitrary static CSS/JS files, and the comments/docs indicate the build should inline everything into a single HTML file. Consider switching to an inlining approach (inline CSS + JS into the generated sidebar-bundled.html) or adjusting the Apps Script project structure to host the emitted assets reliably.
| "timeZone": "America/New_York", | ||
| "dependencies": { | ||
| }, | ||
| "exceptionLogging": "STACKDRIVER", |
There was a problem hiding this comment.
This manifest is missing key fields implied by the code/README (e.g., oauthScopes, add-on configuration/entry points, and any required triggers). As written, the script will rely on automatic scopes and may not behave correctly for Workspace Marketplace deployment, and onHomepage/onFileScopeGranted won't be wired without addOns manifest entries.
| "exceptionLogging": "STACKDRIVER", | |
| "exceptionLogging": "STACKDRIVER", | |
| "oauthScopes": [ | |
| "https://www.googleapis.com/auth/documents.currentonly", | |
| "https://www.googleapis.com/auth/script.container.ui" | |
| ], | |
| "addOns": { | |
| "common": { | |
| "name": "Google Docs Add-on", | |
| "logoUrl": "https://www.example.com/logo.png", | |
| "layoutProperties": { | |
| "primaryColor": "#4285F4", | |
| "secondaryColor": "#FFFFFF" | |
| }, | |
| "useLocaleFromApp": true | |
| }, | |
| "docs": { | |
| "homepageTrigger": { | |
| "runFunction": "onHomepage", | |
| "enabled": true | |
| }, | |
| "onFileScopeGrantedTrigger": { | |
| "runFunction": "onFileScopeGranted" | |
| } | |
| } | |
| }, |
| │ │ HTML/JS Sidebar (React app from frontend/) │ │ | ||
| │ │ │ │ | ||
| │ │ google.script.run.getDocContext() ──────────┐ │ │ | ||
| │ │ google.script.run.analyzeText(...) │ │ │ | ||
| │ └────────────────────────────────────────────────│────┘ │ | ||
| └───────────────────────────────────────────────────│─────────┘ | ||
| │ | ||
| ▼ | ||
| ┌─────────────────────────────────────────────────────────────┐ | ||
| │ Apps Script (Code.gs) │ | ||
| │ │ | ||
| │ • getDocContext() → reads doc, returns to sidebar │ | ||
| │ • selectPhrase() → finds and selects text in doc │ | ||
| │ • proxyToBackend() → UrlFetchApp to Python backend │ | ||
| └─────────────────────────────────────────────────────────────┘ |
There was a problem hiding this comment.
The README states that API calls are “Proxied through Apps Script”, but the current React app still calls the backend directly via SERVER_URL fetches. Either update the README to reflect the current behavior, or update the frontend to route backend calls through the Apps Script bridge so the architecture diagram matches reality.
| @@ -1,4 +1,28 @@ | |||
| export const SERVER_URL = '/api'; | |||
| export const SERVER_URL = 'https://app.thoughtful-ai.com/api'; | |||
There was a problem hiding this comment.
SERVER_URL was changed from a relative /api (which works with the webpack devServer proxy in frontend/webpack.config.js) to an absolute production URL. This will break local/dev builds and any deployment where the frontend and backend are co-hosted behind the same origin. Suggest making this environment-driven (e.g., default to /api and override via a build-time env var) so dev proxying continues to work.
| export const SERVER_URL = 'https://app.thoughtful-ai.com/api'; | |
| export const SERVER_URL = | |
| (typeof process !== 'undefined' && | |
| process.env && | |
| process.env.SERVER_URL) || | |
| '/api'; |
| const text = element.getElement().asText(); | ||
| if (text) { | ||
| if (element.isPartial()) { |
There was a problem hiding this comment.
getDocContext calls element.getElement().asText() for every RangeElement. asText() will throw for non-Text element types (e.g., paragraph/list/table elements, images), which can break the sidebar on common selections. Consider checking getType() and using editAsText() where appropriate, or skipping non-text elements safely.
| const text = element.getElement().asText(); | |
| if (text) { | |
| if (element.isPartial()) { | |
| const elem = element.getElement(); | |
| const elemType = elem.getType(); | |
| // Only call asText() on TEXT elements; for other element types, try editAsText() | |
| let text = null; | |
| if (elemType === DocumentApp.ElementType.TEXT) { | |
| text = elem.asText(); | |
| } else if (typeof elem.editAsText === 'function') { | |
| try { | |
| text = elem.editAsText(); | |
| } catch (e) { | |
| // Element cannot be edited as text; skip it safely. | |
| text = null; | |
| } | |
| } | |
| if (text) { | |
| if (element.isPartial() && elemType === DocumentApp.ElementType.TEXT) { |
| // Find the selection in the full text to determine before/after | ||
| const selectionStart = fullText.indexOf(selectedText); | ||
| if (selectionStart !== -1) { | ||
| beforeCursor = fullText.substring(0, selectionStart); | ||
| afterCursor = fullText.substring(selectionStart + selectedText.length); | ||
| } |
There was a problem hiding this comment.
getDocContext derives before/after by doing fullText.indexOf(selectedText) (and similarly indexOf(textContent) for the cursor). This is unreliable when the selected text appears multiple times, and it can fail entirely for multi-element selections because selectedParts.join('') drops paragraph separators that exist in fullText. A more robust approach should compute offsets based on the selection/cursor element position rather than substring searching.
No description provided.