diff --git a/app/client/components/AceEditor.js b/app/client/components/AceEditor.js index 8cb51a3c..f6ee833c 100644 --- a/app/client/components/AceEditor.js +++ b/app/client/components/AceEditor.js @@ -114,9 +114,9 @@ AceEditor.prototype.enable = function(bool) { * Note: Ace defers to standard behavior when false is returned. */ AceEditor.prototype.attachCommandGroup = function(commandGroup) { - _.each(commandGroup.knownKeys, (command, key) => { + _.each(commandGroup.knownKeys, (commandName, key) => { this.editor.commands.addCommand({ - name: command, + name: commandName, // We are setting readonly as true to enable all commands // in a readonly mode. // Because FieldEditor in readonly mode will rewire all commands that @@ -129,7 +129,7 @@ AceEditor.prototype.attachCommandGroup = function(commandGroup) { }, // AceEditor wants a command to return true if it got handled, whereas our command returns // true to avoid stopPropagation/preventDefault, i.e. if it hasn't been handled. - exec: () => !commandGroup.commands[command]() + exec: () => !commandGroup.commands[commandName]() }); }); }; @@ -270,7 +270,7 @@ AceEditor.prototype.resize = function() { // This won't help for zooming (where the same problem occurs but in many more places), but will // help for Windows users who have different pixel ratio. this.editorDom.style.width = size.width ? Math.ceil(size.width) + 'px' : 'auto'; - this.editorDom.style.height = Math.ceil(size.height) + 'px'; + this.editorDom.style.height = size.height ? Math.ceil(size.height) + 'px' : 'auto'; this.editor.resize(); }; diff --git a/app/client/components/AceEditorCompletions.ts b/app/client/components/AceEditorCompletions.ts index b66fee4f..4bb3b575 100644 --- a/app/client/components/AceEditorCompletions.ts +++ b/app/client/components/AceEditorCompletions.ts @@ -1,4 +1,5 @@ import {ISuggestionWithValue} from 'app/common/ActiveDocAPI'; +import {commonUrls} from 'app/common/gristUrls'; import * as ace from 'brace'; export interface ICompletionOptions { @@ -265,7 +266,7 @@ function retokenizeAceCompleterRow(rowData: AceSuggestion, tokens: TokenInfo[]): // Include into new tokens a special token that will be hidden, but include the link URL. On // click, we find it to know what URL to open. - const href = 'https://support.getgrist.com/functions/#' + + const href = `${commonUrls.functions}/#` + rowData.funcname.slice(linkStart, linkEnd).toLowerCase(); newTokens.push({value: href, type: 'grist_link_hidden'}); diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index ef1b0bae..96b65029 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -49,6 +49,9 @@ const {parsePasteForView} = require("./BaseView2"); const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter'); const {CombinedStyle} = require("app/client/models/Styles"); const {buildRenameColumn} = require('app/client/ui/ColumnTitle'); +const {makeT} = require('app/client/lib/localization'); + +const t = makeT('GridView'); // A threshold for interpreting a motionless click as a click rather than a drag. // Anything longer than this time (in milliseconds) should be interpreted as a drag @@ -219,6 +222,14 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) { // Holds column index that is hovered, works only in full-edit formula mode. this.hoverColumn = ko.observable(-1); + + // Checks if there is active formula editor for a column in this table. + this.editingFormula = ko.pureComputed(() => { + const isEditing = this.gristDoc.docModel.editingFormula(); + if (!isEditing) { return false; } + return this.viewSection.viewFields().all().some(field => field.editingFormula()); + }); + // Debounced method to change current hover column, this is needed // as mouse when moved from field to field will switch the hover-column // observable from current index to -1 and then immediately back to current index. @@ -226,7 +237,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) { // will be discarded. this.changeHover = debounce((index) => { if (this.isDisposed()) { return; } - if (this.gristDoc.docModel.editingFormula()) { + if (this.editingFormula()) { this.hoverColumn(index); } }, 0); @@ -1054,8 +1065,9 @@ GridView.prototype.buildDom = function() { let filterTriggerCtl; const isTooltip = ko.pureComputed(() => - self.gristDoc.docModel.editingFormula() && - ko.unwrap(self.hoverColumn) === field._index()); + self.editingFormula() && + ko.unwrap(self.hoverColumn) === field._index() + ); return dom( 'div.column_name.field', kd.style('--frozen-position', () => ko.unwrap(this.frozenPositions.at(field._index()))), @@ -1070,7 +1082,7 @@ GridView.prototype.buildDom = function() { dom.autoDispose(tooltip), dom.autoDispose(isTooltip.subscribe((show) => { if (show) { - tooltip.show(`Click to insert $${field.colId.peek()}`); + tooltip.show(t(`Click to insert`) + ` $${field.origCol.peek().colId.peek()}`); } else { tooltip.hide(); } @@ -1316,7 +1328,7 @@ GridView.prototype.buildDom = function() { dom.autoDispose(isSelected), dom.on("mouseenter", () => self.changeHover(field._index())), kd.toggleClass("hover-column", () => - self.gristDoc.docModel.editingFormula() && + self.editingFormula() && ko.unwrap(self.hoverColumn) === (field._index())), kd.style('width', field.widthPx), //TODO: Ensure that fields in a row resize when @@ -1624,7 +1636,7 @@ GridView.prototype.dropCols = function() { // column movement, propose renaming the column. if (Date.now() - this._colClickTime < SHORT_CLICK_IN_MS && oldIndices.length === 1 && idx === oldIndices[0]) { - this.currentEditingColumnIndex(idx); + commands.allCommands.renameField.run(); } this._colClickTime = 0; this.cellSelector.currentDragType(selector.NONE); diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 735e4fc4..3f43ffbc 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -28,7 +28,7 @@ import {makeT} from 'app/client/lib/localization'; import {createSessionObs} from 'app/client/lib/sessionObs'; import {setTestState} from 'app/client/lib/testState'; import {selectFiles} from 'app/client/lib/uploads'; -import {reportError} from 'app/client/models/AppModel'; +import {AppModel, reportError} from 'app/client/models/AppModel'; import BaseRowModel from 'app/client/models/BaseRowModel'; import DataTableModel from 'app/client/models/DataTableModel'; import {DataTableModelWithDiff} from 'app/client/models/DataTableModelWithDiff'; @@ -205,6 +205,7 @@ export class GristDoc extends DisposableWithEvents { constructor( public readonly app: App, + public readonly appModel: AppModel, public readonly docComm: DocComm, public readonly docPageModel: DocPageModel, openDocResponse: OpenLocalDocResult, @@ -440,13 +441,7 @@ export class GristDoc extends DisposableWithEvents { // Command to be manually triggered on cell selection. Moves the cursor to the selected cell. // This is overridden by the formula editor to insert "$col" variables when clicking cells. - setCursor(rowModel: BaseRowModel, fieldModel?: ViewFieldRec) { - return this.setCursorPos({ - rowIndex: rowModel?._index() || 0, - fieldIndex: fieldModel?._index() || 0, - sectionId: fieldModel?.viewSection().getRowId(), - }); - }, + setCursor: this.onSetCursorPos.bind(this), }, this, true)); this.listenTo(app.comm, 'docUserAction', this.onDocUserAction); @@ -614,6 +609,14 @@ export class GristDoc extends DisposableWithEvents { return Object.assign(pos, viewInstance ? viewInstance.cursor.getCursorPos() : {}); } + public async onSetCursorPos(rowModel: BaseRowModel|undefined, fieldModel?: ViewFieldRec) { + return this.setCursorPos({ + rowIndex: rowModel?._index() || 0, + fieldIndex: fieldModel?._index() || 0, + sectionId: fieldModel?.viewSection().getRowId(), + }); + } + public async setCursorPos(cursorPos: CursorPos) { if (cursorPos.sectionId && cursorPos.sectionId !== this.externalSectionId.get()) { const desiredSection: ViewSectionRec = this.docModel.viewSections.getRowModel(cursorPos.sectionId); @@ -1149,7 +1152,7 @@ export class GristDoc extends DisposableWithEvents { * Opens up an editor at cursor position * @param input Optional. Cell's initial value */ - public async activateEditorAtCursor(options: { init?: string, state?: any}) { + public async activateEditorAtCursor(options?: { init?: string, state?: any}) { const view = await this._waitForView(); view?.activateEditorAtCursor(options); } diff --git a/app/client/components/Importer.ts b/app/client/components/Importer.ts index 7630a17a..5e7b41f0 100644 --- a/app/client/components/Importer.ts +++ b/app/client/components/Importer.ts @@ -31,8 +31,8 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {byteString} from 'app/common/gutil'; import {FetchUrlOptions, UploadResult} from 'app/common/uploads'; import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI'; -import {Computed, dom, DomContents, fromKo, Holder, IDisposable, MultiHolder, MutableObsArray, obsArray, Observable, - styled} from 'grainjs'; +import {Computed, Disposable, dom, DomContents, fromKo, Holder, IDisposable, + MultiHolder, MutableObsArray, obsArray, Observable, styled} from 'grainjs'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from 'app/client/ui/googleAuth'; import debounce = require('lodash/debounce'); @@ -824,6 +824,7 @@ export class Importer extends DisposableWithEvents { editingFormula: field.editingFormula, refElem, editRow, + canDetach: false, setupCleanup: this._setupFormulaEditorCleanup.bind(this), onSave: async (column, formula) => { if (formula === column.formula.peek()) { return; } @@ -842,7 +843,7 @@ export class Importer extends DisposableWithEvents { * focus. */ private _setupFormulaEditorCleanup( - owner: MultiHolder, _doc: GristDoc, editingFormula: ko.Computed, _saveEdit: () => Promise + owner: Disposable, _doc: GristDoc, editingFormula: ko.Computed, _saveEdit: () => Promise ) { const saveEdit = () => _saveEdit().catch(reportError); diff --git a/app/client/components/commandList.ts b/app/client/components/commandList.ts index 21d1d921..65f03399 100644 --- a/app/client/components/commandList.ts +++ b/app/client/components/commandList.ts @@ -108,6 +108,8 @@ export type CommandName = | 'clearSectionLinks' | 'transformUpdate' | 'clearCopySelection' + | 'detachEditor' + | 'activateAssistant' ; @@ -259,6 +261,11 @@ export const groups: CommendGroupDef[] = [{ keys: [], desc: 'Shortcut to open video tour from home left panel', }, + { + name: 'activateAssistant', + keys: [], + desc: 'Activate assistant', + }, ] }, { group: 'Navigation', @@ -391,6 +398,10 @@ export const groups: CommendGroupDef[] = [{ name: 'fieldEditSave', keys: ['Enter'], desc: 'Finish editing a cell, saving the value' + }, { + name: 'detachEditor', + keys: [''], + desc: 'Detach active editor' }, { name: 'fieldEditSaveHere', keys: [], diff --git a/app/client/components/commands.ts b/app/client/components/commands.ts index 84275282..4fa7daaf 100644 --- a/app/client/components/commands.ts +++ b/app/client/components/commands.ts @@ -8,18 +8,30 @@ */ import * as Mousetrap from 'app/client/lib/Mousetrap'; -import { arrayRemove } from 'app/common/gutil'; +import {arrayRemove, unwrap} from 'app/common/gutil'; import dom from 'app/client/lib/dom'; -import 'app/client/lib/koUtil'; // for subscribeInit import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {CommandDef, CommandName, CommendGroupDef, groups} from 'app/client/components/commandList'; -import {Disposable} from 'grainjs'; +import {Disposable, Observable} from 'grainjs'; import * as _ from 'underscore'; import * as ko from 'knockout'; const G = getBrowserGlobals('window'); -type BoolLike = boolean|ko.Observable|ko.Computed; +type BoolLike = boolean|ko.Observable|ko.Computed|Observable; + +/** + * A helper method that can create a subscription to ko or grains observables. + */ +function subscribe(value: Exclude, fn: (value: boolean) => void) { + if (ko.isObservable(value)) { + return value.subscribe(fn); + } else if (value instanceof Observable) { + return value.addListener(fn); + } else { + throw new Error('Expected an observable'); + } +} // Same logic as used by mousetrap to map 'Mod' key to platform-specific key. export const isMac = (typeof navigator !== 'undefined' && navigator && @@ -291,10 +303,11 @@ export class CommandGroup extends Disposable { this.onDispose(this._removeGroup.bind(this)); // Finally, set the activation status of the command group, subscribing if an observable. - if (ko.isObservable(activate)) { - this.autoDispose((activate as any).subscribeInit(this.activate, this)); - } else { - this.activate(activate as boolean); + if (typeof activate === 'boolean' || activate === undefined) { + this.activate(activate ?? false); + } else if (activate) { + this.autoDispose(subscribe(activate, (val) => this.activate(val))); + this.activate(unwrap(activate)); } } @@ -343,8 +356,8 @@ type BoundedMap = { [key in CommandName]?: BoundedFunc }; /** * Just a shorthand for CommandGroup.create constructor. */ -export function createGroup(commands: BoundedMap, context: T, activate?: BoolLike) { - return CommandGroup.create(null, commands, context, activate); +export function createGroup(commands: BoundedMap|null, context: T, activate?: BoolLike) { + return CommandGroup.create(null, commands ?? {}, context, activate); } //---------------------------------------------------------------------- diff --git a/app/client/lib/koDom.js b/app/client/lib/koDom.js index 7815bb2a..0f19cdac 100644 --- a/app/client/lib/koDom.js +++ b/app/client/lib/koDom.js @@ -249,7 +249,7 @@ function toggleDisabled(boolValueOrFunc) { exports.toggleDisabled = toggleDisabled; /** - * Adds a css class named by an observable value. If the value changes, the previous class will be + * Adds a css class (one or many) named by an observable value. If the value changes, the previous class will be * removed and the new one added. The value may be empty to avoid adding any class. * Similar to knockout's `css` binding with a dynamic class. * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable. @@ -258,11 +258,15 @@ function cssClass(valueOrFunc) { var prevClass; return makeBinding(valueOrFunc, function(elem, value) { if (prevClass) { - elem.classList.remove(prevClass); + for(const name of prevClass.split(' ')) { + elem.classList.remove(name); + } } prevClass = value; if (value) { - elem.classList.add(value); + for (const name of value.split(' ')) { + elem.classList.add(name); + } } }); } diff --git a/app/client/lib/popupUtils.ts b/app/client/lib/popupUtils.ts new file mode 100644 index 00000000..42c32cb4 --- /dev/null +++ b/app/client/lib/popupUtils.ts @@ -0,0 +1,63 @@ +import {dom, Holder, IDisposable, MultiHolder} from 'grainjs'; + +/** + * Overrides the cursor style for the entire document. + * @returns {Disposable} - a Disposable that restores the cursor style to its original value. + */ +export function documentCursor(type: 'ns-resize' | 'grabbing'): IDisposable { + const cursorStyle: HTMLStyleElement = document.createElement('style'); + cursorStyle.innerHTML = `*{cursor: ${type}!important;}`; + cursorStyle.id = 'cursor-style'; + document.head.appendChild(cursorStyle); + const cursorOwner = { + dispose() { + if (this.isDisposed()) { return; } + document.head.removeChild(cursorStyle); + }, + isDisposed() { + return !cursorStyle.isConnected; + } + }; + return cursorOwner; +} + + +/** + * Helper function to create a movable element. + * @param options Handlers for the movable element. + */ +export function movable(options: { + onMove: (dx: number, dy: number, state: T) => void, + onStart: () => T, +}) { + return (el: HTMLElement) => { + // Remember the initial position of the mouse. + let startX = 0; + let startY = 0; + dom.onElem(el, 'mousedown', (md) => { + // Only handle left mouse button. + if (md.button !== 0) { return; } + startX = md.clientX; + startY = md.clientY; + const state = options.onStart(); + + // We create a holder first so that we can dispose elements earlier on mouseup, and have a fallback + // in case of a situation when the dom is removed before mouseup. + const holder = new Holder(); + const owner = MultiHolder.create(holder); + dom.autoDisposeElem(el, holder); + + owner.autoDispose(dom.onElem(document, 'mousemove', (mv) => { + const dx = mv.clientX - startX; + const dy = mv.clientY - startY; + options.onMove(dx, dy, state); + })); + owner.autoDispose(dom.onElem(document, 'mouseup', () => { + holder.clear(); + })); + owner.autoDispose(documentCursor('ns-resize')); + md.stopPropagation(); + md.preventDefault(); + }, { useCapture: true }); + }; +} diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index 24bbc694..0b5d24d2 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -285,7 +285,7 @@ export class AppModelImpl extends Disposable implements AppModel { this.showNewSiteModal(state.params?.planType); } - G.window.resetSeenPopups = (seen = false) => { + G.window.resetDismissedPopups = (seen = false) => { this.dismissedPopups.set(seen ? DismissedPopup.values : []); this.behavioralPromptsManager.reset(); }; diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index eef9d567..84aeb208 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -375,7 +375,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { const comparison = comparisonUrlId ? await this._api.getDocAPI(urlId).compareDoc(comparisonUrlId, { detail: true }) : undefined; - const gristDoc = gdModule.GristDoc.create(flow, this._appObj, docComm, this, openDocResponse, + const gristDoc = gdModule.GristDoc.create(flow, this._appObj, this.appModel, docComm, this, openDocResponse, this.appModel.topAppModel.plugins, {comparison}); // Move ownership of docComm to GristDoc. diff --git a/app/client/models/entities/ColumnRec.ts b/app/client/models/entities/ColumnRec.ts index 245b1193..e38a945f 100644 --- a/app/client/models/entities/ColumnRec.ts +++ b/app/client/models/entities/ColumnRec.ts @@ -200,7 +200,11 @@ export interface ChatMessage { /** * The formula returned from the AI. It is only set when the sender is the AI. */ - formula?: string; + formula?: string|null; + /** + * Suggested actions returned from the AI. + */ + action?: any; } /** diff --git a/app/client/models/entities/ViewFieldRec.ts b/app/client/models/entities/ViewFieldRec.ts index b4671ef5..bb22183b 100644 --- a/app/client/models/entities/ViewFieldRec.ts +++ b/app/client/models/entities/ViewFieldRec.ts @@ -135,9 +135,26 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void // CSS class to add to formula cells, incl. to show that we are editing this field's formula. this.formulaCssClass = ko.pureComputed(() => { const col = this.column(); - return this.column().isTransforming() ? "transform_field" : - (this.editingFormula() ? "formula_field_edit" : - (col.isFormula() && col.formula() !== "" ? "formula_field" : null)); + + // If the current column is transforming, assign the CSS class "transform_field" + if (col.isTransforming()) { + if ( col.origCol().isFormula() && col.origCol().formula() !== "") { + return "transform_field formula_field"; + } + return "transform_field"; + } + // If the column is not transforming but a formula is being edited + else if (this.editingFormula()) { + return "formula_field_edit"; + } + // If a formula exists and it is not empty + else if (col.isFormula() && col.formula() !== "") { + return "formula_field"; + } + // If none of the above conditions are met, assign null + else { + return null; + } }); // The fields's display column diff --git a/app/client/ui/FieldConfig.ts b/app/client/ui/FieldConfig.ts index 903fc78b..680a05e2 100644 --- a/app/client/ui/FieldConfig.ts +++ b/app/client/ui/FieldConfig.ts @@ -3,7 +3,6 @@ import {CursorPos} from 'app/client/components/Cursor'; import {GristDoc} from 'app/client/components/GristDoc'; import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec'; import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight'; -import {buildAiButton} from 'app/client/ui/FormulaAssistance'; import {GristTooltips} from 'app/client/ui/GristTooltips'; import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles'; import {withInfoTooltip} from 'app/client/ui/tooltips'; @@ -13,7 +12,6 @@ import {testId, theme} from 'app/client/ui2018/cssVars'; import {textInput} from 'app/client/ui2018/editableLabel'; import {cssIconButton, icon} from 'app/client/ui2018/icons'; import {IconName} from 'app/client/ui2018/IconList'; -import {GRIST_FORMULA_ASSISTANT} from 'app/client/models/features'; import {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus'; import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor'; import {sanitizeIdent} from 'app/common/gutil'; @@ -134,6 +132,8 @@ export function buildFormulaConfig( // Helper function to clear temporary state (will be called when column changes or formula editor closes) const clearState = () => bundleChanges(() => { + // For a detached editor, we may have already been disposed when user switched page. + if (owner.isDisposed()) { return; } maybeFormula.set(false); maybeTrigger.set(false); formulaField = null; @@ -277,6 +277,8 @@ export function buildFormulaConfig( // Converts column to formula column. const onSaveConvertToFormula = async (column: ColumnRec, formula: string) => { + // For a detached editor, we may have already been disposed when user switched page. + if (owner.isDisposed()) { return; } // For non formula column, we will not convert it to formula column when expression is empty, // as it means we were trying to convert data column to formula column, but changed our mind. const notBlank = Boolean(formula); @@ -362,7 +364,6 @@ export function buildFormulaConfig( ]), formulaBuilder(onSaveConvertToFormula), cssEmptySeparator(), - dom.maybe(GRIST_FORMULA_ASSISTANT(), () => cssRow(buildAiButton(gristDoc, origColumn))), cssRow(textButton( t("Convert to trigger formula"), dom.on("click", convertFormulaToTrigger), diff --git a/app/client/ui/FloatingPopup.ts b/app/client/ui/FloatingPopup.ts index cb0c74bd..6c011013 100644 --- a/app/client/ui/FloatingPopup.ts +++ b/app/client/ui/FloatingPopup.ts @@ -1,7 +1,9 @@ +import {documentCursor} from 'app/client/lib/popupUtils'; import {hoverTooltip} from 'app/client/ui/tooltips'; import {isNarrowScreen, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; -import {Disposable, dom, DomArg, DomContents, IDisposable, makeTestId, Observable, styled} from 'grainjs'; +import {Disposable, dom, DomContents, DomElementArg, + IDisposable, makeTestId, Observable, styled} from 'grainjs'; const POPUP_INITIAL_PADDING_PX = 16; const POPUP_MIN_HEIGHT = 300; @@ -15,9 +17,11 @@ export interface PopupOptions { content?: () => DomContents; onClose?: () => void; closeButton?: boolean; + closeButtonHover?: () => DomContents; autoHeight?: boolean; /** Defaults to false. */ stopClickPropagationOnMove?: boolean; + args?: DomElementArg[]; } export class FloatingPopup extends Disposable { @@ -34,7 +38,7 @@ export class FloatingPopup extends Disposable { private _resize = false; private _cursorGrab: IDisposable|null = null; - constructor(protected _options: PopupOptions = {}, private _args: DomArg[] = []) { + constructor(protected _options: PopupOptions = {}) { super(); if (_options.stopClickPropagationOnMove){ @@ -98,7 +102,7 @@ export class FloatingPopup extends Disposable { } protected _buildArgs(): any { - return this._args; + return this._options.args ?? []; } private _rememberPosition() { @@ -272,12 +276,12 @@ export class FloatingPopup extends Disposable { // Copy buttons on the left side of the header, to automatically // center the title. cssPopupButtons( - !this._options.closeButton ? null : cssPopupHeaderButton( - icon('CrossSmall'), - ), cssPopupHeaderButton( icon('Maximize') ), + !this._options.closeButton ? null : cssPopupHeaderButton( + icon('CrossBig'), + ), dom.style('visibility', 'hidden'), ), cssPopupTitle( @@ -285,19 +289,20 @@ export class FloatingPopup extends Disposable { testId('title'), ), cssPopupButtons( - !this._options.closeButton ? null : cssPopupHeaderButton( - icon('CrossSmall'), - dom.on('click', () => { - this._options.onClose?.() ?? this._closePopup(); - }), - testId('close'), - ), this._popupMinimizeButtonElement = cssPopupHeaderButton( isMinimized ? icon('Maximize'): icon('Minimize'), hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}), dom.on('click', () => this._minimizeOrMaximize()), testId('minimize-maximize'), ), + !this._options.closeButton ? null : cssPopupHeaderButton( + icon('CrossBig'), + dom.on('click', () => { + this._options.onClose?.() ?? this._closePopup(); + }), + testId('close'), + this._options.closeButtonHover && hoverTooltip(this._options.closeButtonHover()) + ), // Disable dragging when a button in the header is clicked. dom.on('mousedown', ev => ev.stopPropagation()), dom.on('touchstart', ev => ev.stopPropagation()), @@ -345,20 +350,7 @@ export class FloatingPopup extends Disposable { private _forceCursor() { this._cursorGrab?.dispose(); const type = this._resize ? 'ns-resize' : 'grabbing'; - const cursorStyle: HTMLStyleElement = document.createElement('style'); - cursorStyle.innerHTML = `*{cursor: ${type}!important;}`; - cursorStyle.id = 'cursor-style'; - document.head.appendChild(cursorStyle); - const cursorOwner = { - dispose() { - if (this.isDisposed()) { return; } - document.head.removeChild(cursorStyle); - }, - isDisposed() { - return !cursorStyle.isConnected; - } - }; - this._cursorGrab = cursorOwner; + this._cursorGrab = documentCursor(type); } } @@ -372,7 +364,7 @@ const POPUP_HEIGHT = `min(var(--height), calc(100% - (2 * ${POPUP_INITIAL_PADDIN const POPUP_HEIGHT_MOBILE = `min(var(--height), calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px) - (2 * 50px)))`; const POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px)))`; -const cssPopup = styled('div', ` +const cssPopup = styled('div.floating-popup', ` position: fixed; display: flex; flex-direction: column; diff --git a/app/client/ui/FormulaAssistance.ts b/app/client/ui/FormulaAssistance.ts deleted file mode 100644 index 2f5b45e3..00000000 --- a/app/client/ui/FormulaAssistance.ts +++ /dev/null @@ -1,605 +0,0 @@ -import * as AceEditor from 'app/client/components/AceEditor'; -import {GristDoc} from 'app/client/components/GristDoc'; -import {ColumnRec} from 'app/client/models/entities/ColumnRec'; -import {buildHighlightedCode} from 'app/client/ui/CodeHighlight'; -import {FloatingPopup} from 'app/client/ui/FloatingPopup'; -import {createUserImage} from 'app/client/ui/UserImage'; -import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons'; -import {theme} from 'app/client/ui2018/cssVars'; -import {cssTextInput, rawTextInput} from 'app/client/ui2018/editableLabel'; -import {icon} from 'app/client/ui2018/icons'; -import {AssistanceResponse, AssistanceState} from 'app/common/AssistancePrompts'; -import {Disposable, dom, makeTestId, MultiHolder, obsArray, Observable, styled} from 'grainjs'; -import noop from 'lodash/noop'; - -const testId = makeTestId('test-assistant-'); - -export function buildAiButton(grist: GristDoc, column: ColumnRec) { - column = grist.docModel.columns.createFloatingRowModel(column.origColRef); - return textButton( - dom.autoDispose(column), - 'Open AI assistant', - testId('open-button'), - dom.on('click', () => openAIAssistant(grist, column)) - ); -} - -interface Context { - grist: GristDoc; - column: ColumnRec; -} - -function buildFormula(owner: MultiHolder, props: Context) { - const { grist, column } = props; - const formula = Observable.create(owner, column.formula.peek()); - const calcSize = (fullDom: HTMLElement, size: any) => { - return { - width: fullDom.clientWidth, - height: size.height, - }; - }; - const editor = AceEditor.create({ - column, - gristDoc: grist, - calcSize, - editorState: formula, - }); - owner.autoDispose(editor); - const buildDom = () => { - return cssFormulaWrapper( - dom.cls('formula_field_sidepane'), - editor.buildDom((aceObj: any) => { - aceObj.setFontSize(11); - aceObj.setHighlightActiveLine(false); - aceObj.getSession().setUseWrapMode(false); - aceObj.renderer.setPadding(0); - setTimeout(() => editor.editor.focus()); - editor.setValue(formula.get()); - }), - ); - }; - return { - buildDom, - set(value: string) { - editor.setValue(value); - }, - get() { - return editor.getValue(); - }, - }; -} - -function buildControls( - owner: MultiHolder, - props: Context & { - currentFormula: () => string; - savedClicked: () => void, - robotClicked: () => void, - } -) { - - const hasHistory = props.column.chatHistory.peek().get().messages.length > 0; - - // State variables, to show various parts of the UI. - const saveButtonVisible = Observable.create(owner, true); - const previewButtonVisible = Observable.create(owner, true); - const robotWellVisible = Observable.create(owner, !hasHistory); - const robotButtonVisible = Observable.create(owner, !hasHistory && !robotWellVisible.get()); - const helpWellVisible = Observable.create(owner, !hasHistory); - - - - // Click handlers - const saveClicked = async () => { - await preview(); - props.savedClicked(); - }; - const previewClicked = async () => await preview(); - const robotClicked = () => props.robotClicked(); - - // Public API - const preview = async () => { - // Currently we don't have a preview, so just save. - const formula = props.currentFormula(); - const tableId = props.column.table.peek().tableId.peek(); - const colId = props.column.colId.peek(); - props.grist.docData.sendAction([ - 'ModifyColumn', - tableId, - colId, - { formula, isFormula: true }, - ]).catch(reportError); - }; - - const buildWells = () => { - return cssContainer( - cssWell( - 'Grist’s AI Formula Assistance. Need help? Our AI assistant can help. ', - textButton('Ask the bot.', dom.on('click', robotClicked)), - dom.show(robotWellVisible) - ), - cssWell( - 'Formula Help. See our Function List and Formula Cheat Sheet, or visit our Community for more help.', - dom.show(helpWellVisible) - ), - dom.show(use => use(robotWellVisible) || use(helpWellVisible)) - ); - }; - const buildDom = () => { - return [ - cssButtonsWrapper( - cssButtons( - primaryButton('Save', dom.show(saveButtonVisible), dom.on('click', saveClicked)), - basicButton('Preview', dom.show(previewButtonVisible), dom.on('click', previewClicked)), - textButton('🤖', dom.show(robotButtonVisible), dom.on('click', robotClicked)), - dom.show( - use => use(previewButtonVisible) || use(saveButtonVisible) || use(robotButtonVisible) - ) - ) - ), - buildWells(), - ]; - }; - return { - buildDom, - preview, - hideHelp() { - robotWellVisible.set(false); - helpWellVisible.set(false); - }, - hideRobot() { - robotButtonVisible.set(false); - } - }; -} - -function buildChat(owner: Disposable, context: Context & { formulaClicked: (formula?: string) => void }) { - const { grist, column } = context; - - const history = owner.autoDispose(obsArray(column.chatHistory.peek().get().messages)); - const hasHistory = history.get().length > 0; - const enabled = Observable.create(owner, hasHistory); - const introVisible = Observable.create(owner, !hasHistory); - owner.autoDispose(history.addListener((cur) => { - const chatHistory = column.chatHistory.peek(); - chatHistory.set({...chatHistory.get(), messages: [...cur]}); - })); - - const submit = async (regenerate: boolean = false) => { - // Send most recent question, and send back any conversation - // state we have been asked to track. - const chatHistory = column.chatHistory.peek().get(); - const messages = chatHistory.messages.filter(msg => msg.sender === 'user'); - const description = messages[messages.length - 1]?.message || ''; - console.debug('description', {description}); - const {reply, suggestedActions, state} = await askAI(grist, { - column, description, state: chatHistory.state, - regenerate, - }); - console.debug('suggestedActions', {suggestedActions, reply}); - const firstAction = suggestedActions[0] as any; - // Add the formula to the history. - const formula = firstAction ? firstAction[3].formula as string : undefined; - // Add to history - history.push({ - message: formula || reply || '(no reply)', - sender: 'ai', - formula - }); - // If back-end is capable of conversation, keep its state. - if (state) { - const chatHistoryNew = column.chatHistory.peek(); - const value = chatHistoryNew.get(); - value.state = state; - chatHistoryNew.set(value); - } - return formula; - }; - - const chatEnterClicked = async (val: string) => { - if (!val) { return; } - // Hide intro. - introVisible.set(false); - // Add question to the history. - history.push({ - message: val, - sender: 'user', - }); - // Submit all questions to the AI. - context.formulaClicked(await submit()); - }; - - const regenerateClick = async () => { - // Remove the last AI response from the history. - history.pop(); - // And submit again. - context.formulaClicked(await submit(true)); - }; - - const newChat = () => { - // Clear the history. - history.set([]); - column.chatHistory.peek().set({messages: []}); - // Show intro. - introVisible.set(true); - }; - - const userPrompt = Observable.create(owner, ''); - - const userImage = () => { - const user = grist.app.topAppModel.appObs.get()?.currentUser || null; - if (user) { - return (createUserImage(user, 'medium')); - } else { - // TODO: this will not happen, as this should be only for logged in users. - return (dom('div', '')); - } - }; - - const buildHistory = () => { - return cssVBox( - dom.forEach(history, entry => { - if (entry.sender === 'user') { - return cssMessage( - cssAvatar(userImage()), - dom.text(entry.message), - ); - } else { - return cssAiMessage( - cssAvatar(cssAiImage()), - buildHighlightedCode(entry.message, { - gristTheme: grist.currentTheme, - maxLines: 10, - }, cssCodeStyles.cls('')), - cssCopyIconWrapper( - icon('Copy', dom.on('click', () => context.formulaClicked(entry.message))), - ) - ); - } - }) - ); - }; - - const buildIntro = () => { - return cssVBox( - dom.cls(cssTopGreenBorder.className), - dom.cls(cssTypography.className), - dom.style('flex-grow', '1'), - dom.style('min-height', '0'), - dom.style('overflow-y', 'auto'), - dom.maybe(introVisible, () => - cssHContainer( - dom.style('margin-bottom', '10px'), - cssVBox( - dom('h4', 'Grist’s AI Assistance'), - dom('h5', 'Tips'), - cssWell( - '“Example prompt” Some instructions for how to draft a prompt. A link to even more examples in support.' - ), - cssWell( - 'Example Values. Instruction about entering example values in the column, maybe with an image?' - ), - dom('h5', 'Capabilities'), - cssWell( - 'Formula Assistance Only. Python code. Spreadsheet functions? May sometimes get it wrong. ' - ), - cssWell('Conversational. Remembers what was said and allows follow-up corrections.'), - dom('h5', 'Data'), - cssWell( - 'Data Usage. Something about how we can see prompts to improve the feature and product, but cannot see doc.' - ), - cssWell( - 'Data Sharing. Something about OpenAI, what’s being transmitted. How does it expose doc data?' - ), - textButton('Learn more', dom.style('align-self', 'flex-start')) - ) - ) - ), - dom.maybe( - use => !use(introVisible), - () => buildHistory() - ), - ); - }; - - const buildButtons = () => { - // We will show buttons only if we have a history. - return dom.maybe(use => use(history).length > 0, () => cssVContainer( - cssHBox( - cssPlainButton(icon('Script'), 'New Chat', dom.on('click', newChat)), - cssPlainButton(icon('Revert'), 'Regenerate', dom.on('click', regenerateClick), dom.style('margin-left', '8px')), - ), - dom.style('padding-bottom', '0'), - dom.style('padding-top', '12px'), - )); - }; - - const buildInput = () => { - return cssHContainer( - dom.cls(cssTopBorder.className), - dom.cls(cssVSpace.className), - cssInputWrapper( - dom.cls(cssTextInput.className), - dom.cls(cssTypography.className), - rawTextInput(userPrompt, chatEnterClicked, noop), - icon('FieldAny') - ), - buildButtons() - ); - }; - - const buildDom = () => { - return dom.maybe(enabled, () => cssVFullBox( - buildIntro(), - cssSpacer(), - buildInput(), - dom.style('overflow', 'hidden'), - dom.style('flex-grow', '1') - )); - }; - - return { - buildDom, - show() { - enabled.set(true); - introVisible.set(true); - } - }; -} - -/** - * Builds and opens up a Formula Popup with an AI assistant. - */ -function openAIAssistant(grist: GristDoc, column: ColumnRec) { - const owner = MultiHolder.create(null); - const props: Context = { grist, column }; - - // Build up all components, and wire up them to each other. - - // First is the formula editor displayed in the upper part of the popup. - const formulaEditor = buildFormula(owner, props); - - // Next are the buttons in the middle. It has a Save, Preview, and Robot button, and probably some wells - // with tips or other buttons. - const controls = buildControls(owner, { - ...props, - // Pass a formula accessor, it is used to get the current formula and apply or preview it. - currentFormula: () => formulaEditor.get(), - // Event or saving, we listen to it to close the popup. - savedClicked() { - grist.formulaPopup.clear(); - }, - // Handler for robot icon click. We hide the robot icon and the help, and show the chat area. - robotClicked() { - chat.show(); - controls.hideHelp(); - controls.hideRobot(); - } - }); - - // Now, the chat area. It has a history of previous questions, and a prompt for the user to ask a new - // question. - const chat = buildChat(owner, {...props, - // When a formula is clicked (or just was returned from the AI), we set it in the formula editor and hit - // the preview button. - formulaClicked: (formula?: string) => { - if (formula) { - formulaEditor.set(formula); - controls.preview().catch(reportError); - } - }, - }); - - const header = `${column.table.peek().tableNameDef.peek()}.${column.label.peek()}`; - const popup = FloatingPopup.create(null, { - title: () => header, - content: () => [ - formulaEditor.buildDom(), - controls.buildDom(), - chat.buildDom(), - ], - onClose: () => grist.formulaPopup.clear(), - closeButton: true, - autoHeight: true, - }); - - popup.autoDispose(owner); - popup.showPopup(); - - // Add this popup to the main holder (and dispose the previous one). - grist.formulaPopup.autoDispose(popup); -} - -async function askAI(grist: GristDoc, options: { - column: ColumnRec, - description: string, - regenerate?: boolean, - state?: AssistanceState -}): Promise { - const {column, description, state, regenerate} = options; - const tableId = column.table.peek().tableId.peek(); - const colId = column.colId.peek(); - try { - const result = await grist.docComm.getAssistance({ - context: {type: 'formula', tableId, colId}, - text: description, - state, - regenerate, - }); - return result; - } catch (error) { - reportError(error); - throw error; - } -} - -const cssVBox = styled('div', ` - display: flex; - flex-direction: column; -`); - -const cssFormulaWrapper = styled('div.formula_field_edit.formula_editor', ` - position: relative; - padding: 5px 0 5px 24px; - flex: auto; - overflow-y: auto; -`); - -const cssVFullBox = styled(cssVBox, ` - flex-grow: 1; -`); - -const cssHBox = styled('div', ` - display: flex; -`); - -const cssVSpace = styled('div', ` - padding-top: 18px; - padding-bottom: 18px; -`); - -const cssHContainer = styled('div', ` - padding-left: 18px; - padding-right: 18px; - display: flex; - flex-direction: column; -`); - -const cssButtonsWrapper = styled(cssHContainer, ` - padding-top: 0; - background-color: ${theme.formulaEditorBg}; -`); - -const cssVContainer = styled('div', ` - padding-top: 18px; - padding-bottom: 18px; - display: flex; - flex-direction: column; -`); - -const cssContainer = styled('div', ` - padding: 18px; - display: flex; - flex-direction: column; -`); - -const cssSpacer = styled('div', ` - margin-top: auto; - display: flex; -`); - -const cssButtons = styled('div', ` - display: flex; - justify-content: flex-end; - gap: 8px; - padding: 8px 0px; -`); - -const cssWell = styled('div', ` - padding: 8px; - color: ${theme.inputFg}; - border-radius: 4px; - background-color: ${theme.rightPanelBg}; - & + & { - margin-top: 8px; - } -`); - -const cssMessage = styled('div', ` - display: grid; - grid-template-columns: 60px 1fr; - padding-right: 54px; - padding-top: 12px; - padding-bottom: 12px; -`); - -const cssAiMessage = styled('div', ` - display: grid; - grid-template-columns: 60px 1fr 54px; - padding-top: 20px; - padding-bottom: 20px; - background: #D9D9D94f; - -`); - -const cssCodeStyles = styled('div', ` - background: #E3E3E3; - border: none; - & .ace-chrome { - background: #E3E3E3; - border: none; - } -`); - -const cssAvatar = styled('div', ` - display: flex; - align-items: flex-start; - justify-content: center; -`); - -const cssAiImage = styled('div', ` - flex: none; - height: 32px; - width: 32px; - border-radius: 50%; - background-color: white; - background-image: var(--icon-GristLogo); - background-size: 22px 22px; - background-repeat: no-repeat; - background-position: center; -`); - -const cssTopGreenBorder = styled('div', ` - border-top: 1px solid ${theme.accentBorder}; -`); - -const cssTypography = styled('div', ` - color: ${theme.inputFg}; -`); - -const cssTopBorder = styled('div', ` - border-top: 1px solid ${theme.inputBorder}; -`); - -const cssCopyIconWrapper = styled('div', ` - display: none; - align-items: center; - justify-content: center; - flex: none; - cursor: pointer; - .${cssAiMessage.className}:hover & { - display: flex; - } -`); - -const cssInputWrapper = styled('div', ` - display: flex; - background-color: ${theme.mainPanelBg}; - align-items: center; - gap: 8px; - padding-right: 8px !important; - --icon-color: ${theme.controlSecondaryFg}; - &:hover, &:focus-within { - --icon-color: ${theme.accentIcon}; - } - & > input { - outline: none; - padding: 0px; - align-self: stretch; - flex: 1; - border: none; - background-color: inherit; - } -`); - -const cssPlainButton = styled(basicButton, ` - border-color: ${theme.inputBorder}; - color: ${theme.controlSecondaryFg}; - --icon-color: ${theme.controlSecondaryFg}; - display: inline-flex; - gap: 10px; - align-items: flex-end; - border-radius: 3px; - padding: 5px 7px; - padding-right: 13px; -`); diff --git a/app/client/ui/forms.ts b/app/client/ui/forms.ts index 3a006fe8..00f06a16 100644 --- a/app/client/ui/forms.ts +++ b/app/client/ui/forms.ts @@ -86,6 +86,7 @@ function resize(el: HTMLTextAreaElement) { export function autoGrow(text: Observable) { return (el: HTMLTextAreaElement) => { el.addEventListener('input', () => resize(el)); + dom.autoDisposeElem(el, text.addListener(() => resize(el))); setTimeout(() => resize(el), 10); dom.autoDisposeElem(el, text.addListener(val => { // Changes to the text are not reflected by the input event (witch is used by the autoGrow) diff --git a/app/client/ui/tooltips.ts b/app/client/ui/tooltips.ts index 2b1ff90d..27d6a54e 100644 --- a/app/client/ui/tooltips.ts +++ b/app/client/ui/tooltips.ts @@ -266,7 +266,11 @@ export function setHoverTooltip( */ export function tooltipCloseButton(ctl: ITooltipControl): HTMLElement { return cssTooltipCloseButton(icon('CrossSmall'), - dom.on('click', () => ctl.close()), + dom.on('mousedown', (ev) =>{ + ev.stopPropagation(); + ev.preventDefault(); + ctl.close(); + }), testId('tooltip-close'), ); } diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index b5be98da..9c255fda 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -113,6 +113,7 @@ export type IconName = "ChartArea" | "ResizePanel" | "Revert" | "RightAlign" | + "Robot" | "Script" | "Search" | "Settings" | @@ -254,6 +255,7 @@ export const IconList: IconName[] = ["ChartArea", "ResizePanel", "Revert", "RightAlign", + "Robot", "Script", "Search", "Settings", diff --git a/app/client/widgets/ConditionalStyle.ts b/app/client/widgets/ConditionalStyle.ts index 44a79bdf..554cfe64 100644 --- a/app/client/widgets/ConditionalStyle.ts +++ b/app/client/widgets/ConditionalStyle.ts @@ -200,6 +200,7 @@ export class ConditionalStyle extends Disposable { editRow: vsi?.moveEditRowToCursor(), refElem, setupCleanup: setupEditorCleanup, + canDetach: false, }); // Add editor to document holder - this will prevent multiple formula editor instances. this._gristDoc.fieldEditorHolder.autoDispose(editorHolder); diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index 2f42c411..657bedd0 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -4,6 +4,8 @@ import { FormulaTransform } from 'app/client/components/FormulaTransform'; import { GristDoc } from 'app/client/components/GristDoc'; import { addColTypeSuffix } from 'app/client/components/TypeConversion'; import { TypeTransform } from 'app/client/components/TypeTransform'; +import { FloatingEditor } from 'app/client/widgets/FloatingEditor'; +import { UnsavedChange } from 'app/client/components/UnsavedChanges'; import dom from 'app/client/lib/dom'; import { KoArray } from 'app/client/lib/koArray'; import * as kd from 'app/client/lib/koDom'; @@ -23,7 +25,7 @@ import { theme } from 'app/client/ui2018/cssVars'; import { IOptionFull, menu, select } from 'app/client/ui2018/menus'; import { DiffBox } from 'app/client/widgets/DiffBox'; import { buildErrorDom } from 'app/client/widgets/ErrorDom'; -import { FieldEditor, saveWithoutEditor, setupEditorCleanup } from 'app/client/widgets/FieldEditor'; +import { FieldEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor'; import { CellDiscussionPopup, EmptyCell } from 'app/client/widgets/DiscussionEditor'; import { openFormulaEditor } from 'app/client/widgets/FormulaEditor'; import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget'; @@ -34,7 +36,7 @@ import * as gristTypes from 'app/common/gristTypes'; import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes'; import { CellValue } from 'app/plugin/GristData'; import { Computed, Disposable, fromKo, dom as grainjsDom, - Holder, IDisposable, makeTestId, MultiHolder, styled, toKo } from 'grainjs'; + makeTestId, MultiHolder, Observable, styled, toKo } from 'grainjs'; import * as ko from 'knockout'; import * as _ from 'underscore'; @@ -100,19 +102,19 @@ export class FieldBuilder extends Disposable { private readonly _rowMap: Map; private readonly _isTransformingFormula: ko.Computed; private readonly _isTransformingType: ko.Computed; - private readonly _fieldEditorHolder: Holder; private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>; private readonly _docModel: DocModel; private readonly _readonly: Computed; private readonly _comments: ko.Computed; private readonly _showRefConfigPopup: ko.Observable; + private readonly _isEditorActive = Observable.create(this, false); public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec, private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) { super(); this._docModel = gristDoc.docModel; - this.origColumn = field.column(); + this.origColumn = field.origCol(); this.options = field.widgetOptionsJson; this._comments = ko.pureComputed(() => toKo(ko, COMMENTS())()); @@ -183,10 +185,6 @@ export class FieldBuilder extends Disposable { (this.columnTransform instanceof TypeTransform); })); - // This holds a single FieldEditor. When a new FieldEditor is created (on edit), it replaces the - // previous one if any. - this._fieldEditorHolder = Holder.create(this); - // Map from rowModel to cell dom for the field to which this fieldBuilder applies. this._rowMap = new Map(); @@ -580,7 +578,7 @@ export class FieldBuilder extends Disposable { if (this.isDisposed()) { return null; } // Work around JS errors during field removal. const value = row.cells[this.field.colId()]; const cell = value && value(); - if ((value) && this._isRightType()(cell, this.options) || row._isAddRow.peek()) { + if ((value as any) && this._isRightType()(cell, this.options) || row._isAddRow.peek()) { return this.widgetImpl(); } else if (gristTypes.isVersions(cell)) { return this.diffImpl; @@ -677,39 +675,40 @@ export class FieldBuilder extends Disposable { return; } + // Clear previous editor. Some caveats: + // - The floating editor has an async cleanup routine, but it promises that it won't affect as. + // - All other editors should be synchronous, so this line will remove all opened editors. + const holder = this.gristDoc.fieldEditorHolder; + // If the global editor is from our own field, we will dispose it immediately, otherwise we will + // rely on the clipboard to dispose it by grabbing focus. + const clearOwn = () => this.isEditorActive() && holder.clear(); + // If this is censored value, don't open up the editor, unless it is a formula field. const cell = editRow.cells[this.field.colId()]; const value = cell && cell(); if (gristTypes.isCensored(value) && !this.origColumn.isFormula.peek()) { - this._fieldEditorHolder.clear(); - return; + return clearOwn(); } const editorCtor: typeof NewBaseEditor = UserTypeImpl.getEditorConstructor(this.options().widget, this._readOnlyPureType()); // constructor may be null for a read-only non-formula field, though not today. if (!editorCtor) { - // Actually, we only expect buildEditorDom() to be called when isEditorActive() is false (i.e. - // _fieldEditorHolder is already clear), but clear here explicitly for clarity. - this._fieldEditorHolder.clear(); - return; + return clearOwn(); } - // if editor doesn't support readonly mode, don't show it if (this._readonly.get() && editorCtor.supportsReadonly && !editorCtor.supportsReadonly()) { - this._fieldEditorHolder.clear(); - return; + return clearOwn(); } if (!this._readonly.get() && saveWithoutEditor(editorCtor, editRow, this.field, options.init)) { - this._fieldEditorHolder.clear(); - return; + return clearOwn(); } const cellElem = this._rowMap.get(mainRowModel)!; // The editor may dispose itself; the Holder will know to clear itself in this case. - const fieldEditor = FieldEditor.create(this._fieldEditorHolder, { + const fieldEditor = FieldEditor.create(holder, { gristDoc: this.gristDoc, field: this.field, cursor: this._cursor, @@ -720,15 +719,13 @@ export class FieldBuilder extends Disposable { startVal: this._readonly.get() ? undefined : options.init, // don't start with initial value readonly: this._readonly.get() // readonly for editor will not be observable }); - - // Put the FieldEditor into a holder in GristDoc too. This way any existing FieldEditor (perhaps - // for another field, or for another BaseView) will get disposed at this time. The reason to - // still maintain a Holder in this FieldBuilder is mainly to match older behavior; changing that - // will entail a number of other tweaks related to the order of creating and disposal. - this.gristDoc.fieldEditorHolder.autoDispose(fieldEditor); + this._isEditorActive.set(true); // expose the active editor in a grist doc as an observable - fieldEditor.onDispose(() => this.gristDoc.activeEditor.set(null)); + fieldEditor.onDispose(() => { + this._isEditorActive.set(false); + this.gristDoc.activeEditor.set(null); + }); this.gristDoc.activeEditor.set(fieldEditor); } @@ -742,11 +739,12 @@ export class FieldBuilder extends Disposable { if (editRow._isAddRow.peek() || this._readonly.get()) { return; } + const holder = this.gristDoc.fieldEditorHolder; const cell = editRow.cells[this.field.colId()]; const value = cell && cell(); if (gristTypes.isCensored(value)) { - this._fieldEditorHolder.clear(); + holder.clear(); return; } @@ -770,7 +768,8 @@ export class FieldBuilder extends Disposable { } public isEditorActive() { - return !this._fieldEditorHolder.isEmpty(); + const holder = this.gristDoc.fieldEditorHolder; + return !holder.isEmpty() && this._isEditorActive.get(); } /** @@ -782,19 +781,74 @@ export class FieldBuilder extends Disposable { editValue?: string, onSave?: (column: ColumnRec, formula: string) => Promise, onCancel?: () => void) { - const editorHolder = openFormulaEditor({ + // Remember position when the popup was opened. + const position = this.gristDoc.cursorPosition.get(); + + // Create a controller for the floating editor. It is primarily responsible for moving the editor + // dom from the place where it was rendered to the popup (and moving it back). + const floatController = { + attach: async (content: HTMLElement) => { + // If we haven't change page and the element is still in the DOM, move the editor to the + // back to where it was rendered. It still has it's content, so no need to dispose it. + if (refElem.isConnected) { + formulaEditor.attach(refElem); + } else { + // Else, we will navigate to the position we left off, dispose the editor and the content. + formulaEditor.dispose(); + grainjsDom.domDispose(content); + await this.gristDoc.recursiveMoveToCursorPos(position!, true); + } + }, + detach() { + return formulaEditor.detach(); + }, + autoDispose(el: Disposable) { + return formulaEditor.autoDispose(el); + }, + dispose() { + formulaEditor.dispose(); + } + }; + + // Create a custom cleanup method, that won't destroy us when we loose focus while being detached. + function setupEditorCleanup( + owner: MultiHolder, gristDoc: GristDoc, + editingFormula: ko.Computed, _saveEdit: () => Promise + ) { + // Just override the behavior on focus lost. + const saveOnFocus = () => floatingExtension.active.get() ? void 0 : _saveEdit().catch(reportError); + UnsavedChange.create(owner, async () => { await saveOnFocus(); }); + gristDoc.app.on('clipboard_focus', saveOnFocus); + owner.onDispose(() => { + gristDoc.app.off('clipboard_focus', saveOnFocus); + editingFormula(false); + }); + } + + // Get the field model from metatables, as the one provided by the caller might be some floating one, that + // will change when user navigates around. + const field = this.gristDoc.docModel.viewFields.getRowModel(this.field.getRowId()); + + // Finally create the editor passing only the field, which will enable detachable flavor of formula editor. + const formulaEditor = openFormulaEditor({ gristDoc: this.gristDoc, - column: this.field.column(), + field, editingFormula: this.field.editingFormula, setupCleanup: setupEditorCleanup, editRow, refElem, editValue, + canDetach: true, onSave, onCancel }); + + // And now create the floating editor itself. It is just a floating wrapper that will grab the dom + // from the editor and show it in the popup. It also overrides various parts of Grist to make smoother experience. + const floatingExtension = FloatingEditor.create(formulaEditor, floatController, this.gristDoc); + // Add editor to document holder - this will prevent multiple formula editor instances. - this.gristDoc.fieldEditorHolder.autoDispose(editorHolder); + this.gristDoc.fieldEditorHolder.autoDispose(formulaEditor); } } diff --git a/app/client/widgets/FieldEditor.ts b/app/client/widgets/FieldEditor.ts index 97cfb3dc..32d28902 100644 --- a/app/client/widgets/FieldEditor.ts +++ b/app/client/widgets/FieldEditor.ts @@ -1,5 +1,5 @@ import * as commands from 'app/client/components/commands'; -import {Cursor} from 'app/client/components/Cursor'; +import {Cursor, CursorPos} from 'app/client/components/Cursor'; import {GristDoc} from 'app/client/components/GristDoc'; import {UnsavedChange} from 'app/client/components/UnsavedChanges'; import {makeT} from 'app/client/lib/localization'; @@ -12,9 +12,10 @@ import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEdit import {asyncOnce} from "app/common/AsyncCreate"; import {CellValue} from "app/common/DocActions"; import * as gutil from 'app/common/gutil'; -import {Disposable, Emitter, Holder, MultiHolder} from 'grainjs'; -import isEqual = require('lodash/isEqual'); import {CellPosition} from "app/client/components/CellPosition"; +import {FloatingEditor} from 'app/client/widgets/FloatingEditor'; +import isEqual = require('lodash/isEqual'); +import {Disposable, dom, Emitter, Holder, MultiHolder, Observable} from 'grainjs'; type IEditorConstructor = typeof NewBaseEditor; @@ -63,6 +64,7 @@ export class FieldEditor extends Disposable { public readonly saveEmitter = this.autoDispose(new Emitter()); public readonly cancelEmitter = this.autoDispose(new Emitter()); public readonly changeEmitter = this.autoDispose(new Emitter()); + public floatingEditor: FloatingEditor; private _gristDoc: GristDoc; private _field: ViewFieldRec; @@ -76,6 +78,8 @@ export class FieldEditor extends Disposable { private _editorHasChanged = false; private _isFormula = false; private _readonly = false; + private _detached = Observable.create(this, false); + private _detachedAt: CursorPos|null = null; constructor(options: { gristDoc: GristDoc, @@ -154,6 +158,9 @@ export class FieldEditor extends Disposable { this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, options.state); + // Create a floating editor, which will be used to display the editor in a popup. + this.floatingEditor = FloatingEditor.create(this, this, this._gristDoc); + if (offerToMakeFormula) { this._offerToMakeFormula(); } @@ -162,9 +169,16 @@ export class FieldEditor extends Disposable { // when user or server refreshes the browser this._gristDoc.editorMonitor.monitorEditor(this); + // For detached editor, we don't need to cleanup anything. + // It will be cleanuped automatically. + const onCleanup = async () => { + if (this._detached.get()) { return; } + await this._saveEdit(); + }; + // for readonly field we don't need to do anything special if (!options.readonly) { - setupEditorCleanup(this, this._gristDoc, this._field.editingFormula, this._saveEdit); + setupEditorCleanup(this, this._gristDoc, this._field.editingFormula, onCleanup); } else { setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit()); } @@ -190,7 +204,13 @@ export class FieldEditor extends Disposable { cellValue = cellCurrentValue; } - const error = getFormulaError(this._gristDoc, this._editRow, column); + const errorHolder = new MultiHolder(); + + const error = getFormulaError(errorHolder, { + gristDoc: this._gristDoc, + editRow: this._editRow, + field: this._field + }); // For readonly mode use the default behavior of Formula Editor // TODO: cleanup this flag - it gets modified in too many places @@ -198,9 +218,11 @@ export class FieldEditor extends Disposable { // Enter formula-editing mode (e.g. click-on-column inserts its ID) only if we are opening the // editor by typing into it (and overriding previous formula). In other cases (e.g. double-click), // we defer this mode until the user types something. - this._field.editingFormula(this._isFormula && editValue !== undefined); + const active = this._isFormula && editValue !== undefined; + this._field.editingFormula(active); } + this._detached.set(false); this._editorHasChanged = false; // Replace the item in the Holder with a new one, disposing the previous one. const editor = this._editorHolder.autoDispose(editorCtor.create({ @@ -214,10 +236,13 @@ export class FieldEditor extends Disposable { editValue, cursorPos, state, + canDetach: true, commands: this._editCommands, readonly : this._readonly })); + editor.autoDispose(errorHolder); + // if editor supports live changes, connect it to the change emitter if (editor.editorState) { editor.autoDispose(editor.editorState.addListener((currentState) => { @@ -235,6 +260,28 @@ export class FieldEditor extends Disposable { editor.attach(this._cellElem); } + public detach() { + this._detached.set(true); + this._detachedAt = this._gristDoc.cursorPosition.get()!; + return this._editorHolder.get()!.detach()!; + } + + public async attach(content: HTMLElement) { + // If we are disconnected from the dom (maybe page was changed or something), we can't + // simply attach the editor back, we need to rebuild it. + if (!this._cellElem.isConnected) { + dom.domDispose(content); + if (await this._gristDoc.recursiveMoveToCursorPos(this._detachedAt!, true)) { + await this._gristDoc.activateEditorAtCursor(); + } + this.dispose(); + return; + } + this._detached.set(false); + this._editorHolder.get()?.attach(this._cellElem); + this._field.viewSection.peek().hasFocus(true); + } + public getDom() { return this._editorHolder.get()?.getDom(); } @@ -242,7 +289,7 @@ export class FieldEditor extends Disposable { // calculate current cell's absolute position public cellPosition() { const rowId = this._editRow.getRowId(); - const colRef = this._field.colRef.peek(); + const colRef = this._field.column.peek().origColRef.peek(); const sectionId = this._field.viewSection.peek().id.peek(); const position = { rowId, @@ -344,7 +391,7 @@ export class FieldEditor extends Disposable { col.updateColValues({isFormula, formula}), // If we're saving a non-empty formula, then also add an empty record to the table // so that the formula calculation is visible to the user. - (this._editRow._isAddRow.peek() && formula !== "" ? + (!this._detached.get() && this._editRow._isAddRow.peek() && formula !== "" ? this._editRow.updateColValues({}) : undefined), ])); } diff --git a/app/client/widgets/FloatingEditor.ts b/app/client/widgets/FloatingEditor.ts new file mode 100644 index 00000000..96a99a3a --- /dev/null +++ b/app/client/widgets/FloatingEditor.ts @@ -0,0 +1,129 @@ +import * as commands from 'app/client/components/commands'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {detachNode} from 'app/client/lib/dom'; +import {FocusLayer} from 'app/client/lib/FocusLayer'; +import {FloatingPopup} from 'app/client/ui/FloatingPopup'; +import {theme} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {Disposable, dom, Holder, IDisposableOwner, IDomArgs, + makeTestId, MultiHolder, Observable, styled} from 'grainjs'; + +export interface IFloatingOwner extends IDisposableOwner { + detach(): HTMLElement; + attach(content: HTMLElement): Promise|void; +} + +const testId = makeTestId('test-floating-editor-'); + +export class FloatingEditor extends Disposable { + + public active = Observable.create(this, false); + + constructor(private _fieldEditor: IFloatingOwner, private _gristDoc: GristDoc) { + super(); + this.autoDispose(commands.createGroup({ + detachEditor: this.createPopup.bind(this), + }, this, true)); + } + + public createPopup() { + const editor = this._fieldEditor; + + const popupOwner = Holder.create(editor); + const tempOwner = new MultiHolder(); + try { + // Create a layer to grab the focus, when we will move the editor to the popup. Otherwise the focus + // will be moved to the clipboard which can destroy us (as it will be treated as a clickaway). So here + // we are kind of simulating always focused editor (even if it is not in the dom for a brief moment). + FocusLayer.create(tempOwner, { defaultFocusElem: document.activeElement as any}); + + // Take some data from gristDoc to create a title. + const cursor = this._gristDoc.cursorPosition.get()!; + const vs = this._gristDoc.docModel.viewSections.getRowModel(cursor.sectionId!); + const table = vs.tableId.peek(); + const field = vs.viewFields.peek().at(cursor.fieldIndex!)!; + const title = `${table}.${field.label.peek()}`; + + let content: HTMLElement; + // Now create the popup. It will be owned by the editor itself. + const popup = FloatingPopup.create(popupOwner, { + content: () => (content = editor.detach()), // this will be called immediately, and will move some dom between + // existing editor and the popup. We need to save it, so we can + // detach it on close. + title: () => title, // We are not reactive yet + closeButton: true, // Show the close button with a hover + closeButtonHover: () => 'Return to cell', + onClose: async () => { + const layer = FocusLayer.create(null, { defaultFocusElem: document.activeElement as any}); + try { + detachNode(content); + popupOwner.dispose(); + await editor.attach(content); + } finally { + layer.dispose(); + } + }, + args: [testId('popup')] + }); + // Set a public flag that we are active. + this.active.set(true); + popup.onDispose(() => { + this.active.set(false); + }); + + // Show the popup with the editor. + popup.showPopup(); + } finally { + // Dispose the focus layer, we only needed it for the time when the dom was moved between parents. + tempOwner.dispose(); + } + } +} + +export function createDetachedIcon(...args: IDomArgs) { + return cssResizeIconWrapper( + cssSmallIcon('Maximize'), + dom.on('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + commands.allCommands.detachEditor.run(); + }), + dom.on('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + }), + testId('detach-button'), + ...args + ); +} + +const cssSmallIcon = styled(icon, ` + width: 14px; + height: 14px; +`); + +const cssResizeIconWrapper = styled('div', ` + position: absolute; + right: -2px; + top: -20px; + line-height: 0px; + cursor: pointer; + z-index: 10; + --icon-color: ${theme.cellBg}; + background: var(--grist-theme-control-primary-bg, var(--grist-primary-fg)); + height: 20px; + width: 21px; + --icon-color: white; + display: flex; + align-items: center; + justify-content: center; + line-height: 0px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + &:hover { + background: var(--grist-theme-control-primary-hover-bg, var(--grist-primary-fg-hover)) + } + & > div { + transition: background .05s ease-in-out; + } +`); diff --git a/app/client/widgets/FormulaAssistant.ts b/app/client/widgets/FormulaAssistant.ts new file mode 100644 index 00000000..8a577663 --- /dev/null +++ b/app/client/widgets/FormulaAssistant.ts @@ -0,0 +1,1101 @@ +import * as commands from 'app/client/components/commands'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {makeT} from 'app/client/lib/localization'; +import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel'; +import {ChatMessage} from 'app/client/models/entities/ColumnRec'; +import {GRIST_FORMULA_ASSISTANT} from 'app/client/models/features'; +import {buildHighlightedCode} from 'app/client/ui/CodeHighlight'; +import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; +import {createUserImage} from 'app/client/ui/UserImage'; +import {loadingDots} from 'app/client/ui2018/loaders'; +import {FormulaEditor} from 'app/client/widgets/FormulaEditor'; +import {AssistanceResponse, AssistanceState} from 'app/common/AssistancePrompts'; +import {commonUrls} from 'app/common/gristUrls'; +import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons'; +import {theme} from 'app/client/ui2018/cssVars'; +import {autoGrow} from 'app/client/ui/forms'; +import {IconName} from 'app/client/ui2018/IconList'; +import {icon} from 'app/client/ui2018/icons'; +import {cssLink} from 'app/client/ui2018/links'; +import {DocAction} from 'app/common/DocActions'; +import {movable} from 'app/client/lib/popupUtils'; + +import debounce from 'lodash/debounce'; +import {Computed, Disposable, dom, DomContents, DomElementArg, keyframes, + makeTestId, + MutableObsArray, obsArray, Observable, styled} from 'grainjs'; + import noop from 'lodash/noop'; +import {marked} from 'marked'; + +const t = makeT('FormulaEditor'); +const testId = makeTestId('test-formula-editor-'); + +/** + * An extension or the FormulaEditor that provides assistance for writing formulas. + * It renders itself in the detached FormulaEditor and adds some extra UI elements. + * - Save button: a subscription for the Enter key that saves the formula and closes the assistant. + * - Preview button: a new functionality that allows to preview the formula in a temporary column. + * - Two info cards: that describes what this is and how to use it. + * - A chat component: that allows to communicate with the assistant. + */ +export class FormulaAssistant extends Disposable { + /** Chat component */ + private _chat: ChatHistory; + /** State of the user input */ + private _userInput = Observable.create(this, ''); + /** Is formula description card dismissed */ + private _isFormulaInfoClosed: Observable; + /** Is Ai card dismissed */ + private _isAssistantInfoClosed: Observable; + /** Are any cards dismissed */ + private _cardsVisible: Observable; + /** Dom element that holds the user input */ + // TODO: move it to a separate component + private _input: HTMLTextAreaElement; + /** Do we need to show an intro, we show it when history is empty */ + private _introVisible: Observable; + /** Do we need to show a robot icon, we show it when history is empty and assistant is disabled */ + private _robotIconVisible: Observable; + /** Is chat active, we show it when history is not empty */ + private _chatActive = Observable.create(this, false); + /** Is the request pending */ + private _waiting = Observable.create(this, false); + /** Is this feature enabled at all */ + private _assistantEnabled = GRIST_FORMULA_ASSISTANT(); + /** Preview column id */ + private _transformColId: string; + /** Method to invoke when we are closed, it saves or reverts */ + private _triggerFinalize: (() => void) = noop; + /** What action button was clicked, by default close without saving */ + private _action: 'save' | 'cancel' | 'close' = 'close'; + // Our dom element (used for resizing). + private _domElement: HTMLElement; + // Input wrapper element (used for resizing). + private _inputWrapper: HTMLElement; + /** + * Debounced version of the method that will force parent editor to resize, we call it often + * as we have an ability to resize the chat window. + */ + private _resizeEditor = debounce(() => { + if (!this.isDisposed()) { + this._options.editor.resize(); + } + }, 10); + + constructor(private _options: { + column: ColumnRec, + field?: ViewFieldRec, + gristDoc: GristDoc, + editor: FormulaEditor + }) { + super(); + + if (!this._options.field) { + // TODO: field is not passed only for rules (as there is no preview there available to the user yet) + // this should be implemented but it requires creating a helper column to helper column and we don't + // have infrastructure for that yet. + throw new Error('Formula assistant requires a field to be passed.'); + } + + this._chat = ChatHistory.create(this, { + ...this._options, + copyClicked: this._copyClicked.bind(this), + }); + + const hasHistory = Computed.create(this, use => use(this._chat.length) > 0); + if (hasHistory.get()) { + this._chatActive.set(true); + } + + this.autoDispose(commands.createGroup({ + activateAssistant: () => { + this._robotIconClicked(); + setTimeout(() => { + this._input.focus(); + }, 0); + } + }, this, true)); + + // Calculate some flags what to show when. + this._isFormulaInfoClosed = this.autoDispose(_options.gristDoc.appModel.dismissedPopup('formulaHelpInfo')); + this._isAssistantInfoClosed = this.autoDispose(_options.gristDoc.appModel.dismissedPopup('formulaAssistantInfo')); + this._cardsVisible = Computed.create(this, use => { + const seenInfo = use(this._isFormulaInfoClosed); + const seenAi = use(this._isAssistantInfoClosed); + const aiEnable = use(this._assistantEnabled); + const nothingToShow = seenInfo && (seenAi || !aiEnable); + if (nothingToShow) { + return false; + } + if (use(hasHistory)) { + return false; + } + if (use(this._chatActive)) { + return false; + } + return true; + }); + this._introVisible = Computed.create(this, use => { + if (use(hasHistory)) { + return false; + } + if (use(this._chatActive)) { + return true; + } + return false; + }); + this._robotIconVisible = Computed.create(this, use => { + if (!use(this._assistantEnabled)) { return false; } + if (use(hasHistory)) { + return false; + } + if (use(this._introVisible)) { + return false; + } + if (use(this._chatActive)) { + return false; + } + return true; + }); + + // Unfortunately we need to observe the size of the formula editor dom and resize it accordingly. + const observer = new ResizeObserver(this._resizeEditor); + observer.observe(this._options.editor.getDom()); + this.onDispose(() => observer.disconnect()); + + // Start bundling all actions from this moment on and close the editor as soon, + // as user tries to do something different. + const bundleInfo = this._options.gristDoc.docData.startBundlingActions({ + description: 'Formula Editor', + prepare: () => this._preparePreview(), + finalize: () => this._cleanupPreview(), + shouldIncludeInBundle: (a) => { + const tableId = this._options.column.table.peek().tableId.peek(); + const allowed = a.length === 1 + && a[0][0] === 'ModifyColumn' + && a[0][1] === tableId + && typeof a[0][2] === 'string' + && [this._transformColId, this._options.column.id.peek()].includes(a[0][2]); + return allowed; + } + }); + + this._triggerFinalize = bundleInfo.triggerFinalize; + this.onDispose(() => { + // This will be noop if already called. + this._triggerFinalize(); + }); + } + + // The main dom added to the editor and the bottom (3 buttons and chat window). + public buildDom() { + // When the tools are resized, resize the editor. + const observer = new ResizeObserver(this._resizeEditor); + this._domElement = cssTools( + (el) => observer.observe(el), + dom.onDispose(() => observer.disconnect()), + cssButtons( + primaryButton(t('Save'), dom.on('click', () => { + this.saveOrClose(); + }), testId('save-button')), + basicButton(t('Preview'), dom.on('click', async () => { + await this.preview(); + }), testId('preview-button')), + this.buildInlineRobotButton(), + ), + this.buildInfoCards(), + this.buildChat(), + ); + return this._domElement; + } + + public buildInfoCards() { + return cssCardList( + dom.show(this._cardsVisible), + dom.maybe(use => use(this._assistantEnabled) && !use(this._isAssistantInfoClosed), () => + buildCard({ + close: () => this._isAssistantInfoClosed.set(true), + icon: "Robot", + title: t("Grist's AI Formula Assistance. "), + content: dom('span', + t('Need help? Our AI assistant can help.'), ' ', + textButton(t('Ask the bot.'), dom.on('click', this._robotIconClicked.bind(this)),), + ), + args: [testId('ai-well')] + }), + ), + dom.maybe(use => !use(this._isFormulaInfoClosed), () => + buildCard({ + close: () => this._isFormulaInfoClosed.set(true), + icon: 'Help', + title: t("Formula Help. "), + content: dom('span', + t('See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.', { + helpFunction: cssLink(t('Function List'), {href: commonUrls.functions, target: '_blank'}), + formulaCheat: cssLink(t('Formula Cheat Sheet'), {href: commonUrls.formulaSheet, target: '_blank'}), + community: cssLink(t('Community'), {href: commonUrls.community, target: '_blank'}), + })), + args: [testId('formula-well')] + }) + ), + ); + } + + public buildChat() { + return dom.maybe(this._assistantEnabled, () => { + setTimeout(() => { + if (!this.isDisposed()) { + // Scroll to the bottom of the chat right after it is rendered without the animation. + this._chat.scrollDown(false); + } + this._options.editor.resize(); + }, 0); + return cssChat( + testId('chat'), + dom.maybe(this._chatActive, () => [ + cssTopGreenBorder( + movable({ + onStart: this._onResizeStart.bind(this), + onMove: this._onResizeMove.bind(this), + }) + ), + ]), + this._buildIntro(), + this._chat.buildDom(), + this._buildChatInput(), + ); + }); + } + + + /** + * Save button handler. We just store the action and wait for the bundler to finalize. + */ + public saveOrClose() { + this._action = 'save'; + this._triggerFinalize(); + } + + /** + * Cancel button handler. + */ + public async cancel() { + this._action = 'cancel'; + this._triggerFinalize(); + } + + /** + * Preview button handler. + */ + public async preview() { + const tableId = this._options.column.table.peek().tableId.peek(); + // const colId = this._options.column.colId.peek(); + const formula = this._options.editor.getCellValue(); + const isFormula = true; + await this._options.gristDoc.docData.sendAction( + ['ModifyColumn', tableId, this._transformColId, {formula, isFormula} + ]); + if (!this.isDisposed()) { + this._options.editor.focus(); + } + } + + public buildInlineRobotButton() { + return cssRobotButton( + icon('Robot'), + dom.show(this._robotIconVisible), + dom.on('click', this._robotIconClicked.bind(this)), + testId('robot-button'), + ); + } + + private async _preparePreview() { + const docData = this._options.gristDoc.docData; + const tableId = this._options.column.table.peek().tableId.peek(); + + // Add a new column to the table, and set it as the transform column. + const colInfo = await docData.sendAction(['AddColumn', tableId, 'gristHelper_Transform', { + type: this._options.column.type.peek(), + label: this._options.column.colId.peek(), + isFormula: true, + formula: this._options.column.formula.peek(), + }]); + this._options.field?.colRef(colInfo.colRef); // Don't save, it is only in browser. + this._transformColId = colInfo.colId; + + // Update the transform column so that it points to the original column. + const transformColumn = this._options.field?.column.peek(); + if (transformColumn) { + transformColumn.isTransforming(true); + this._options.column.isTransforming(true); + transformColumn.origColRef(this._options.column.getRowId()); // Don't save + } + } + + private async _cleanupPreview() { + // Mark that we did finalize already. + this._triggerFinalize = noop; + const docData = this._options.gristDoc.docData; + const tableId = this._options.column.table.peek().tableId.peek(); + const column = this._options.column; + try { + if (this._action === 'save') { + const formula = this._options.editor.getCellValue(); + // Modify column right away, so that it looks smoother on the ui, when we + // switch the column for the field. + await docData.sendActions([ + ['ModifyColumn', tableId, column.colId.peek(), { formula, isFormula: true}], + ]); + } + // Switch the column for the field, this isn't sending any actions, we are just restoring it to what it is + // in database. But now the column has already correct data as it was already calculated. + this._options.field?.colRef(column.getRowId()); + + // Now trigger the action in our owner that should dispose us. The save + // method will be no op if we saved anything. + if (this._action === 'save') { + commands.allCommands.fieldEditSaveHere.run(); + } else if (this._action === 'cancel') { + commands.allCommands.fieldEditCancel.run(); + } else { + if (this._action !== 'close') { + throw new Error('Unexpected value for _action'); + } + if (!this.isDisposed()) { + commands.allCommands.fieldEditCancel.run(); + } + } + await docData.sendActions([ + ['RemoveColumn', tableId, this._transformColId] + ]); + } finally { + // Repeat the change, in case of an error. + this._options.field?.colRef(column.getRowId()); + column.isTransforming(false); + } + } + + private _onResizeStart() { + const start = this._domElement?.clientHeight; + const total = this._options.editor.getDom().clientHeight; + return { + start, total + }; + } + + /** + * Resize handler for the chat window. + */ + private _onResizeMove(x: number, y: number, {start, total}: {start: number, total: number}) { + // We want to keep the formula well at least 100px tall. + const minFormulaHeight = 100; + // The total height of the tools, input and resize line. + const toolsHeight = 43 + this._inputWrapper.clientHeight + 7; + const desiredHeight = start - y; + // Calculate the correct height in the allowed range. + const calculatedHeight = Math.max(toolsHeight + 10, Math.min(total - minFormulaHeight, desiredHeight)); + this._domElement.style.height = `${calculatedHeight}px`; + } + + /** + * Builds the chat input at the bottom of the chat. + */ + private _buildChatInput() { + // Make sure we dispose the previous input. + if (this._input) { + dom.domDispose(this._input); + } + const ask = () => this._ask(); + // Input is created by hand, as we need a finer control of the user input than what is available + // in generic textInput control. + this._input = cssInput( + dom.on('input', (ev: Event) => { + this._userInput.set((ev.target as HTMLInputElement).value); + }), + autoGrow(this._userInput), + dom.onKeyDown({ + Enter$: (ev) => { + // If shift is pressed, we want to insert a new line. + if (!ev.shiftKey) { + ev.preventDefault(); + ask().catch(reportError); + } + }, + Escape: this.cancel.bind(this), + }), + dom.autoDispose(this._userInput.addListener(value => this._input.value = value)), + dom.prop('disabled', this._waiting), + dom.autoDispose(this._waiting.addListener(value => { + if (!value) { + setTimeout(() => this._input.focus(), 0); + } + })), + ); + return this._inputWrapper = cssHContainer( + testId('chat-input'), + dom.style('margin-top', 'auto'), + dom.cls(cssTopBorder.className), + dom.cls(cssVSpace.className), + dom.on('click', () => this._input.focus()), + dom.show(this._chatActive), + cssInputWrapper( + dom.cls(cssTypography.className), + this._input, + dom.domComputed(this._waiting, (waiting) => { + if (!waiting) { return cssClickableIcon('FieldAny', dom.on('click', ask)); } + else { return cssLoadingDots(); } + }) + ), + cssVContainer( + cssHBox( + cssPlainButton( + icon('Script'), + t('New Chat'), + dom.on('click', this._clear.bind(this)), + testId('chat-new') + ), + cssPlainButton(icon('Revert'), t('Regenerate'), + dom.on('click', this._regenerate.bind(this)), dom.style('margin-left', '8px'), + testId('chat-regenerate') + ), + ), + dom.style('padding-bottom', '0'), + dom.style('padding-top', '12px'), + ) + ); + } + + /** + * Builds the intro section of the chat panel. TODO the copy. + */ + private _buildIntro() { + return dom.maybe(this._introVisible, () => cssInfo( + testId('chat-intro'), + cssTopHeader(t("Grist's AI Assistance")), + cssHeader(t('Tips')), + cssCardList( + buildCard({ + title: 'Example prompt: ', + content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ', + }), + buildCard({ + title: 'Example Values: ', + content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ', + }), + ), + cssHeader(t('Capabilities')), + cssCardList( + buildCard({ + title: 'Example prompt: ', + content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ', + }), + buildCard({ + title: 'Example Values: ', + content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ', + }), + ), + cssHeader(t('Data')), + cssCardList( + buildCard({ + title: 'Data usage. ', + content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ', + }), + buildCard({ + title: 'Data sharing. ', + content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ', + }), + ) + )); + } + + private _robotIconClicked() { + this._chatActive.set(true); + } + + private _copyClicked(entry: ChatMessage) { + this._options.editor.setFormula(entry.formula!); + } + + private async _sendMessage(description: string, regenerate = false) { + // Destruct options. + const {column, gristDoc} = this._options; + // Get the state of the chat from the column. + const prevState = column.chatHistory.peek().get().state; + // Send the message back to the AI with previous state and a mark that we want to regenerate. + // We can't modify the state here as we treat it as a black box, so we only removed last message + // from ai from the chat, we grabbed last question and we are sending it back to the AI with a + // flag that it should clear last response and regenerate it. + const {reply, suggestedActions, state} = await askAI(gristDoc, { + column, description, state: prevState, + regenerate, + }); + console.debug('suggestedActions', {suggestedActions, reply, state}); + // If back-end is capable of conversation, keep its state. + const chatHistoryNew = column.chatHistory.peek(); + const value = chatHistoryNew.get(); + value.state = state; + const formula = (suggestedActions[0]?.[3] as any)?.formula as string; + // If model has a conversational skills (and maintains a history), we might get actually + // some markdown text back, so we need to parse it. + const prettyMessage = state ? (reply || formula || '') : (formula || reply || ''); + // Add it to the chat. + this._chat.addResponse(prettyMessage, formula, suggestedActions[0]); + } + + private _clear() { + this._chat.clear(); + this._userInput.set(''); + } + + private async _regenerate() { + if (this._waiting.get()) { + return; + } + this._chat.removeLastResponse(); + const last = this._chat.lastQuestion(); + if (!last) { + return; + } + this._chat.thinking(); + this._waiting.set(true); + await this._sendMessage(last, true).finally(() => this._waiting.set(false)); + } + + private async _ask() { + if (this._waiting.get()) { + return; + } + const message= this._userInput.get(); + if (!message) { return; } + this._chat.addQuestion(message); + this._chat.thinking(); + this._userInput.set(''); + this._waiting.set(true); + await this._sendMessage(message, false).finally(() => this._waiting.set(false)); + } +} + +/** + * A model for the chat panel. It is responsible for keeping the history of the chat and + * sending messages to the AI. + */ +class ChatHistory extends Disposable { + public history: MutableObsArray; + public length: Computed; + + private _element: HTMLElement; + + constructor(private _options: { + column: ColumnRec, + gristDoc: GristDoc, + copyClicked: (entry: ChatMessage) => void, + }) { + super(); + const column = this._options.column; + // Create observable array of messages that is connected to the column's chatHistory. + this.history = this.autoDispose(obsArray(column.chatHistory.peek().get().messages)); + this.autoDispose(this.history.addListener((cur) => { + const chatHistory = column.chatHistory.peek(); + chatHistory.set({...chatHistory.get(), messages: [...cur]}); + })); + this.length = Computed.create(this, use => use(this.history).length); // ?? + } + + public thinking() { + this.history.push({ + message: '...', + sender: 'ai', + }); + this.scrollDown(); + } + + public supportsMarkdown() { + return this._options.column.chatHistory.peek().get().state !== undefined; + } + + public addResponse(message: string, formula: string|null, action?: DocAction) { + // Clear any thinking from messages. + this.history.set(this.history.get().filter(x => x.message !== '...')); + this.history.push({ + message, + sender: 'ai', + formula, + action + }); + this.scrollDown(); + } + + public addQuestion(message: string) { + this.history.set(this.history.get().filter(x => x.message !== '...')); + this.history.push({ + message, + sender: 'user', + }); + } + + public lastQuestion() { + const list = this.history.get(); + if (list.length === 0) { + return null; + } + const lastMessage = list[list.length - 1]; + if (lastMessage?.sender === 'user') { + return lastMessage.message; + } + throw new Error('No last question found'); + } + + public removeLastResponse() { + const lastMessage = this.history.get()[this.history.get().length - 1]; + if (lastMessage?.sender === 'ai') { + this.history.pop(); + } + } + + public clear() { + this.history.set([]); + const {column} = this._options; + // Get the state of the chat from the column. + const prevState = column.chatHistory.peek().get(); + prevState.state = undefined; + } + + public scrollDown(smooth = true) { + this._element.scroll({ + top: 99999, + behavior: smooth ? 'smooth' : 'auto' + }); + } + + public buildDom() { + return this._element = cssHistory( + dom.forEach(this.history, entry => { + if (entry.sender === 'user') { + return cssMessage( + cssAvatar(buildAvatar(this._options.gristDoc)), + dom('span', + dom.text(entry.message), + testId('user-message'), + testId('chat-message'), + ) + ); + } else { + return cssAiMessage( + cssAvatar(cssAiImage()), + entry.message === '...' ? cssCursor() : + this._render(entry.message, + testId('assistant-message'), + testId('chat-message'), + ), + cssCopyIconWrapper( + dom.show(Boolean(entry.formula)), + icon('Copy', dom.on('click', () => this._options.copyClicked(entry))), + ) + ); + } + }) + ); + } + + /** + * Renders the message as markdown if possible, otherwise as a code block. + */ + private _render(message: string, ...args: DomElementArg[]) { + const doc = this._options.gristDoc; + if (this.supportsMarkdown()) { + return dom('div', + (el) => { + const content = sanitizeHTML(marked(message, { + highlight: (code) => { + const codeBlock = buildHighlightedCode(code, { + gristTheme: doc.currentTheme, + maxLines: 60, + }, cssCodeStyles.cls('')); + return codeBlock.innerHTML; + }, + })); + el.innerHTML = content; + }, + ...args + ); + + } else { + return buildHighlightedCode(message, { + gristTheme: doc.currentTheme, + maxLines: 100, + }, cssCodeStyles.cls('')); + } + } +} + +/** + * Sends the message to the backend and returns the response. + */ +async function askAI(grist: GristDoc, options: { + column: ColumnRec, + description: string, + regenerate?: boolean, + state?: AssistanceState +}): Promise { + const {column, description, state, regenerate} = options; + const tableId = column.table.peek().tableId.peek(); + const colId = column.colId.peek(); + try { + const result = await grist.docComm.getAssistance({ + context: {type: 'formula', tableId, colId}, + text: description, + state, + regenerate, + }); + return result; + } catch (error) { + reportError(error); + throw error; + } +} + +/** + * Builds a card with the given title and content. + */ +function buildCard(options: { + icon?: IconName, + title: string, + content: DomContents, + close?: () => void, + args?: DomElementArg[] +}) { + return cssCard( + options.icon && dom('div', cssCard.cls(`-icon`), icon(options.icon)), + dom('div', cssCard.cls('-body'), dom('span', + dom('span', cssCard.cls('-title'), options.title), + dom('span', cssCard.cls('-content'), options.content), + )), + options.icon && dom('div', + dom.on('click', options.close ?? noop), + cssCard.cls('-close'), + icon('CrossSmall'), + testId('well-close'), + ), + ...(options.args ?? []) + ); +} + +/** Builds avatar image for user or assistant. */ +function buildAvatar(grist: GristDoc) { + const user = grist.app.topAppModel.appObs.get()?.currentUser || null; + if (user) { + return (createUserImage(user, 'medium')); + } else { + // TODO: this will not happen, as this should be only for logged in users. + return (dom('div', '')); + } +} + +// TODO: for now this icon is hidden as more design is needed. It overlaps various elements. +const detachRobotVisible = false; +export function buildRobotIcon() { + if (!detachRobotVisible) { return null; } + return dom.maybe(GRIST_FORMULA_ASSISTANT(), () => + cssDetachedRobotIcon( + 'Robot', + dom.on('click', () => { + commands.allCommands.detachEditor.run(); + commands.allCommands.activateAssistant.run(); + }), + testId('detached-robot-icon'), + ) + ); +} + +const cssDetachedRobotIcon = styled(icon, ` + left: -25px; + --icon-color: ${theme.iconButtonPrimaryBg}; + position: absolute; + cursor: pointer; + &:hover { + --icon-color: ${theme.iconButtonPrimaryHoverBg}; + } +`); + +const cssInfo = styled('div', ` + overflow: auto; + height: 100%; +`); + +const cssTopHeader = styled('div', ` + font-size: 20px; + padding-left: 16px; + padding-right: 16px; + margin-top: 20px; + color: ${theme.inputFg}; +`); + +const cssHeader = styled('div', ` + font-size: 16px; + padding-left: 16px; + padding-right: 16px; + margin: 10px 0px; + color: ${theme.inputFg}; +`); + + +const cssTopGreenBorder = styled('div', ` + background: ${theme.accentBorder}; + height: 7px; + border-top: 3px solid ${theme.pageBg}; + border-bottom: 3px solid ${theme.pageBg}; + cursor: ns-resize; + flex: none; +`); + +const cssChat = styled('div', ` + overflow: hidden; + display: flex; + flex-direction: column; + flex-grow: 1; +`); + + +const cssRobotButton = styled('div', ` + padding-left: 9px; + padding-right: 9px; + padding-top: 4px; + padding-bottom: 6px; + margin-left: -8px; + --icon-color: ${theme.controlPrimaryBg}; + cursor: pointer; + &:hover { + --icon-color: ${theme.controlPrimaryHoverBg}; + } +`); + +const cssCardList = styled('div', ` + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + padding-top: 0px; + & a { + font-weight: bold; + } +`); + +const cssCard = styled('div', ` + position: relative; + display: flex; + column-gap: 8px; + padding: 8px; + padding-bottom: 12px; + color: ${theme.inputFg}; + border-radius: 4px; + background-color: ${theme.cardCompactWidgetBg}; + &-icon { + --icon-color: ${theme.accentText}; + } + &-title { + font-weight: 600; + } + &-close { + position: absolute; + top: 4px; + right: 4px; + height: 20px; + width: 20px; + cursor: pointer; + } + &-close:hover { + background-color: ${theme.pageHoverBg}; + --icon-color: ${theme.linkHover}; + border-radius: 4px; + } + &-body { + padding-top: 2px; + padding-right: 12px; + line-height: 1.6em; + } + & button { + font-weight: 600; + } +`); + +const cssTopBorder = styled('div', ` + border-top: 1px solid ${theme.inputBorder}; +`); + +const cssVSpace = styled('div', ` + padding-top: 18px; + padding-bottom: 18px; +`); + +const cssHContainer = styled('div', ` + padding-left: 18px; + padding-right: 18px; + display: flex; + flex-shrink: 0; + flex-direction: column; +`); + +const cssTypography = styled('div', ` + color: ${theme.inputFg}; +`); + +const cssHBox = styled('div', ` + display: flex; +`); + +const cssVContainer = styled('div', ` + padding-top: 18px; + padding-bottom: 18px; + display: flex; + flex-direction: column; +`); + + +const cssHistory = styled('div', ` + overflow: auto; + display: flex; + flex-direction: column; + color: ${theme.inputFg}; +`); + + +const cssPlainButton = styled(basicButton, ` + border-color: ${theme.inputBorder}; + color: ${theme.controlSecondaryFg}; + --icon-color: ${theme.controlSecondaryFg}; + display: inline-flex; + gap: 10px; + align-items: flex-end; + border-radius: 3px; + padding: 5px 7px; + padding-right: 13px; +`); + +const cssInputWrapper = styled('div', ` + display: flex; + border: 1px solid ${theme.inputBorder}; + border-radius: 3px; + background-color: ${theme.mainPanelBg}; + align-items: center; + gap: 8px; + padding-right: 8px !important; + --icon-color: ${theme.controlSecondaryFg}; + &:hover, &:focus-within { + --icon-color: ${theme.accentIcon}; + } + & > input { + outline: none; + padding: 0px; + align-self: stretch; + flex: 1; + border: none; + background-color: inherit; + } +`); + +const cssMessage = styled('div', ` + display: grid; + grid-template-columns: 60px 1fr; + padding-right: 54px; + padding-top: 12px; + padding-bottom: 12px; +`); + +const cssAiMessage = styled('div', ` + display: grid; + grid-template-columns: 60px 1fr 54px; + padding-top: 20px; + padding-bottom: 20px; + background: #D9D9D94f; + & pre { + background: ${theme.cellBg}; + font-size: 10px; + } +`); + +const cssCodeStyles = styled('div', ` + background: #E3E3E3; + border: none; + & .ace-chrome { + background: #E3E3E3; + border: none; + } +`); + +const cssAvatar = styled('div', ` + display: flex; + align-items: flex-start; + justify-content: center; +`); + +const cssAiImage = styled('div', ` + flex: none; + height: 32px; + width: 32px; + border-radius: 50%; + background-color: white; + background-image: var(--icon-GristLogo); + background-size: 22px 22px; + background-repeat: no-repeat; + background-position: center; +`); + + +const cssCopyIconWrapper = styled('div', ` + display: none; + align-items: center; + justify-content: center; + flex: none; + cursor: pointer; + .${cssAiMessage.className}:hover & { + display: flex; + } +`); + +const blink = keyframes(` + 0% { opacity: 1; } + 50% { opacity: 0; } + 100% { opacity: 1; } +`); + +const cssCursor = styled('div', ` + height: 1rem; + width: 3px; + background-color: ${theme.darkText}; + animation: ${blink} 1s infinite; +`); + +const cssLoadingDots = styled(loadingDots, ` + --dot-size: 4px; +`); + + +const cssButtons = styled('div', ` + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 8px; +`); + +const cssTools = styled('div._tools_container', ` + display: flex; + flex-direction: column; + overflow: hidden; +`); + +const cssClickableIcon = styled(icon, ` + cursor: pointer; +`); + + +const cssInput = styled('textarea', ` + border: 0px; + flex-grow: 1; + outline: none; + padding: 4px 6px; + padding-top: 6px; + resize: none; + min-height: 28px; + background: transparent; +} +`); diff --git a/app/client/widgets/FormulaEditor.ts b/app/client/widgets/FormulaEditor.ts index 1f595168..6c818488 100644 --- a/app/client/widgets/FormulaEditor.ts +++ b/app/client/widgets/FormulaEditor.ts @@ -1,22 +1,26 @@ import * as AceEditor from 'app/client/components/AceEditor'; -import {createGroup} from 'app/client/components/commands'; +import {CommandName} from 'app/client/components/commandList'; +import * as commands from 'app/client/components/commands'; +import {GristDoc} from 'app/client/components/GristDoc'; import {makeT} from 'app/client/lib/localization'; import {DataRowModel} from 'app/client/models/DataRowModel'; +import {ColumnRec} from 'app/client/models/DocModel'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; +import {reportError} from 'app/client/models/errors'; +import {GRIST_FORMULA_ASSISTANT} from 'app/client/models/features'; import {colors, testId, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons'; import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement'; +import {createDetachedIcon} from 'app/client/widgets/FloatingEditor'; +import {buildRobotIcon, FormulaAssistant} from 'app/client/widgets/FormulaAssistant'; import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor'; -import {undef} from 'app/common/gutil'; -import {Computed, Disposable, dom, MultiHolder, Observable, styled, subscribe} from 'grainjs'; -import {isRaisedException} from "app/common/gristTypes"; -import {decodeObject, RaisedException} from "app/plugin/objtypes"; -import {GristDoc} from 'app/client/components/GristDoc'; -import {ColumnRec} from 'app/client/models/DocModel'; import {asyncOnce} from 'app/common/AsyncCreate'; -import {reportError} from 'app/client/models/errors'; import {CellValue} from 'app/common/DocActions'; +import {isRaisedException} from 'app/common/gristTypes'; +import {undef} from 'app/common/gutil'; +import {decodeObject, RaisedException} from 'app/plugin/objtypes'; +import {Computed, Disposable, dom, Holder, MultiHolder, Observable, styled, subscribe} from 'grainjs'; import debounce = require('lodash/debounce'); // How wide to expand the FormulaEditor when an error is shown in it. @@ -25,8 +29,10 @@ const t = makeT('FormulaEditor'); export interface IFormulaEditorOptions extends Options { cssClass?: string; - editingFormula: ko.Computed, - column: ColumnRec, + editingFormula: ko.Computed; + column: ColumnRec; + field?: ViewFieldRec; + canDetach?: boolean; } @@ -42,10 +48,13 @@ export interface IFormulaEditorOptions extends Options { * should save the value on `blur` event. */ export class FormulaEditor extends NewBaseEditor { + public isDetached = Observable.create(this, false); + protected options: IFormulaEditorOptions; + private _formulaEditor: any; - private _commandGroup: any; private _dom: HTMLElement; private _editorPlacement!: EditorPlacement; + private _placementHolder = Holder.create(this); constructor(options: IFormulaEditorOptions) { super(options); @@ -67,19 +76,50 @@ export class FormulaEditor extends NewBaseEditor { readonly: options.readonly }); - const allCommands = !options.readonly - ? Object.assign({ setCursor: this._onSetCursor }, options.commands) - // for readonly mode don't grab cursor when clicked away - just move the cursor - : options.commands; - this._commandGroup = this.autoDispose(createGroup(allCommands, this, editingFormula)); + + // For editable editor we will grab the cursor when we are in the formula editing mode. + const cursorCommands = options.readonly ? {} : { setCursor: this._onSetCursor }; + const isActive = Computed.create(this, use => Boolean(use(editingFormula))); + const commandGroup = this.autoDispose(commands.createGroup(cursorCommands, this, isActive)); + + // We will create a group of editor commands right away. + const editorGroup = this.autoDispose(commands.createGroup({ + ...options.commands, + }, this, true)); + + // Merge those two groups into one. + const aceCommands: any = { + knownKeys: {...commandGroup.knownKeys, ...editorGroup.knownKeys}, + commands: {...commandGroup.commands, ...editorGroup.commands}, + }; + + // Tab, Shift + Tab, Enter should be handled by the editor itself when we are in the detached mode. + // We will create disabled group, but will push those commands to the editor directly. + const passThrough = (name: CommandName) => () => { + if (this.isDetached.get()) { + // For detached editor, just leave the default behavior. + return true; + } + // Else invoke regular command. + commands.allCommands[name]?.run(); + return false; + }; + const detachedCommands = this.autoDispose(commands.createGroup({ + nextField: passThrough('nextField'), + prevField: passThrough('prevField'), + fieldEditSave: passThrough('fieldEditSave'), + }, this, false /* don't activate, we're just borrowing constructor */)); + + Object.assign(aceCommands.knownKeys, detachedCommands.knownKeys); + Object.assign(aceCommands.commands, detachedCommands.commands); const hideErrDetails = Observable.create(this, true); const raisedException = Computed.create(this, use => { - if (!options.formulaError) { + if (!options.formulaError || !use(options.formulaError)) { return null; } - const error = isRaisedException(use(options.formulaError)) ? - decodeObject(use(options.formulaError)) as RaisedException: + const error = isRaisedException(use(options.formulaError)!) ? + decodeObject(use(options.formulaError)!) as RaisedException: new RaisedException(["Unknown error"]); return error; }); @@ -98,10 +138,13 @@ export class FormulaEditor extends NewBaseEditor { // Once the exception details are available, update the sizing. The extra delay is to allow // the DOM to update before resizing. - this.autoDispose(errorDetails.addListener(() => setTimeout(() => this._formulaEditor.resize(), 0))); + this.autoDispose(errorDetails.addListener(() => setTimeout(this.resize.bind(this), 0))); + + const canDetach = GRIST_FORMULA_ASSISTANT().get() && options.canDetach && !options.readonly; this.autoDispose(this._formulaEditor); - this._dom = dom('div.default_editor.formula_editor_wrapper', + this._dom = cssFormulaEditor( + buildRobotIcon(), // switch border shadow dom.cls("readonly_editor", options.readonly), createMobileButtons(options.commands), @@ -109,9 +152,28 @@ export class FormulaEditor extends NewBaseEditor { // This shouldn't be needed, but needed for tests. dom.on('mousedown', (ev) => { + // If we are detached, allow user to click and select error text. + if (this.isDetached.get()) { + // If the focus is already in this editor, don't steal it. This is needed for detached editor with + // some input elements (mainly the AI assistant). + const inInput = document.activeElement instanceof HTMLInputElement + || document.activeElement instanceof HTMLTextAreaElement; + if (inInput && this._dom.contains(document.activeElement)) { + return; + } + // Allow clicking the error message. + if (ev.target instanceof HTMLElement && ( + ev.target.classList.contains('error_msg') || + ev.target.classList.contains('error_details_inner') + )) { + return; + } + } ev.preventDefault(); - this._formulaEditor.getEditor().focus(); + this.focus(); }), + canDetach ? createDetachedIcon(dom.hide(this.isDetached)) : null, + cssFormulaEditor.cls('-detached', this.isDetached), dom('div.formula_editor.formula_field_edit', testId('formula-editor'), this._formulaEditor.buildDom((aceObj: any) => { aceObj.setFontSize(11); @@ -121,7 +183,7 @@ export class FormulaEditor extends NewBaseEditor { const val = initialValue; const pos = Math.min(options.cursorPos, val.length); this._formulaEditor.setValue(val, pos); - this._formulaEditor.attachCommandGroup(this._commandGroup); + this._formulaEditor.attachCommandGroup(aceCommands); // enable formula editing if state was passed if (options.state || options.readonly) { @@ -132,48 +194,74 @@ export class FormulaEditor extends NewBaseEditor { aceObj.gotoLine(0, 0); // By moving, ace editor won't highlight anything } // This catches any change to the value including e.g. via backspace or paste. - aceObj.once("change", () => editingFormula?.(true)); + aceObj.once("change", () => { + editingFormula?.(true); + }); }) ), - (options.formulaError ? [ - dom('div.error_msg', testId('formula-error-msg'), - dom.on('click', () => { - if (errorDetails.get()){ - hideErrDetails.set(!hideErrDetails.get()); - this._formulaEditor.resize(); - } - }), - dom.maybe(errorDetails, () => - dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(hide ? 'Expand' : 'Collapse')) - ), - dom.text(errorText), + dom.maybe(options.formulaError, () => [ + dom('div.error_msg', testId('formula-error-msg'), + dom.on('click', () => { + if (this.isDetached.get()) { return; } + if (errorDetails.get()){ + hideErrDetails.set(!hideErrDetails.get()); + this._formulaEditor.resize(); + } + }), + dom.maybe(errorDetails, () => + dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon( + hide ? 'Expand' : 'Collapse', + testId('formula-error-expand'), + dom.on('click', () => { + if (!this.isDetached.get()) { return; } + if (errorDetails.get()){ + hideErrDetails.set(!hideErrDetails.get()); + this._formulaEditor.resize(); + } + }) + )) ), - dom.maybe(use => Boolean(use(errorDetails) && !use(hideErrDetails)), () => - dom('div.error_details', - dom('div.error_details_inner', - dom.text(errorDetails), - ), - testId('formula-error-details'), - ) + dom.text(errorText), + ), + dom.maybe(use => Boolean(use(errorDetails) && !use(hideErrDetails)), () => + dom('div.error_details', + dom('div.error_details_inner', + dom.text(errorDetails), + ), + testId('formula-error-details'), ) - ] : null - ) + ) + ]), + dom.maybe(this.isDetached, () => { + return dom.create(FormulaAssistant, { + column: this.options.column, + field: this.options.field, + gristDoc: this.options.gristDoc, + editor: this, + }); + }), ); } public attach(cellElem: Element): void { - this._editorPlacement = EditorPlacement.create(this, this._dom, cellElem, {margins: getButtonMargins()}); + this.isDetached.set(false); + this._editorPlacement = EditorPlacement.create( + this._placementHolder, this._dom, cellElem, {margins: getButtonMargins()}); // Reposition the editor if needed for external reasons (in practice, window resize). - this.autoDispose(this._editorPlacement.onReposition.addListener( - this._formulaEditor.resize, this._formulaEditor)); + this.autoDispose(this._editorPlacement.onReposition.addListener(this._formulaEditor.resize, this._formulaEditor)); this._formulaEditor.onAttach(); - this._formulaEditor.editor.focus(); + this._formulaEditor.resize(); + this.focus(); } public getDom(): HTMLElement { return this._dom; } + public setFormula(formula: string) { + this._formulaEditor.setValue(formula); + } + public getCellValue() { const value = this._formulaEditor.getValue(); // Strip the leading "=" sign, if any, in case users think it should start the formula body (as @@ -190,14 +278,47 @@ export class FormulaEditor extends NewBaseEditor { return aceObj.getSession().getDocument().positionToIndex(aceObj.getCursorPosition()); } + public focus() { + if (this.isDisposed()) { return; } + this._formulaEditor.getEditor().focus(); + } + + public resize() { + if (this.isDisposed()) { return; } + this._formulaEditor.resize(); + } + + public detach() { + // Remove the element from the dom (to prevent any autodispose) from happening. + this._dom.parentNode?.removeChild(this._dom); + // First mark that we are detached, to show the buttons, + // and halt the autosizing mechanism. + this.isDetached.set(true); + // Finally, destroy the normal inline placement helper. + this._placementHolder.clear(); + // We are going in the full formula edit mode right away. + this.options.editingFormula(true); + // Set the focus in timeout, as the dom is added after this function. + setTimeout(() => !this.isDisposed() && this._formulaEditor.resize(), 0); + // Return the dom, it will be moved to the floating editor. + return this._dom; + } + private _calcSize(elem: HTMLElement, desiredElemSize: ISize) { + if (this.isDetached.get()) { + // If we are detached, we will stop autosizing. + return { + height: 0, + width: 0 + }; + } const errorBox: HTMLElement|null = this._dom.querySelector('.error_details'); const errorBoxStartHeight = errorBox?.getBoundingClientRect().height || 0; const errorBoxDesiredHeight = errorBox?.scrollHeight || 0; // If we have an error to show, ask for a larger size for formulaEditor. const desiredSize = { - width: Math.max(desiredElemSize.width, (this.options.formulaError ? minFormulaErrorWidth : 0)), + width: Math.max(desiredElemSize.width, (this.options.formulaError.get() ? minFormulaErrorWidth : 0)), // Ask for extra space for the error; we'll decide how to allocate it below. height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight), }; @@ -216,35 +337,42 @@ export class FormulaEditor extends NewBaseEditor { } // TODO: update regexes to unicode? - private _onSetCursor(row: DataRowModel, col: ViewFieldRec) { - - if (!col) { return; } // if clicked on row header, no col to insert - + private _onSetCursor(row?: DataRowModel, col?: ViewFieldRec) { + // Don't do anything when we are readonly. if (this.options.readonly) { return; } + // If we don't have column information, we can't insert anything. + if (!col) { return; } + + const colId = col.origCol.peek().colId.peek(); const aceObj = this._formulaEditor.getEditor(); - if (!aceObj.selection.isEmpty()) { // If text selected, replace whole selection - aceObj.session.replace(aceObj.selection.getRange(), '$' + col.colId()); + // Rect only to columns in the same table. + if (col.tableId.peek() !== this.options.column.table.peek().tableId.peek()) { + // aceObj.focus(); + this.options.gristDoc.onSetCursorPos(row, col).catch(reportError); + return; + } - } else { // Not a selection, gotta figure out what to replace + if (!aceObj.selection.isEmpty()) { + // If text selected, replace whole selection + aceObj.session.replace(aceObj.selection.getRange(), '$' + colId); + } else { + // Not a selection, gotta figure out what to replace const pos = aceObj.getCursorPosition(); const line = aceObj.session.getLine(pos.row); const result = _isInIdentifier(line, pos.column); // returns {start, end, id} | null - - if (!result) { // Not touching an identifier, insert colId as normal - aceObj.insert("$" + col.colId()); - - // We are touching an identifier - } else if (result.ident.startsWith("$")) { // If ident is a colId, replace it - - const idRange = AceEditor.makeRange(pos.row, result.start, pos.row, result.end); - aceObj.session.replace(idRange, "$" + col.colId()); + if (!result) { + // Not touching an identifier, insert colId as normal + aceObj.insert('$' + colId); + // We are touching an identifier + } else if (result.ident.startsWith('$')) { + // If ident is a colId, replace it + const idRange = AceEditor.makeRange(pos.row, result.start, pos.row, result.end); + aceObj.session.replace(idRange, '$' + colId); } - - // Else touching a normal identifier, dont mangle it + // Else touching a normal identifier, don't mangle it } - // Resize editor in case it is needed. this._formulaEditor.resize(); aceObj.focus(); @@ -276,6 +404,7 @@ export function openFormulaEditor(options: { gristDoc: GristDoc, // Associated formula from a different column (for example style rule). column?: ColumnRec, + // Associated formula from a view field. If provided together with column, this field is used field?: ViewFieldRec, editingFormula?: ko.Computed, // Needed to get exception value, if any. @@ -285,24 +414,38 @@ export function openFormulaEditor(options: { editValue?: string, onSave?: (column: ColumnRec, formula: string) => Promise, onCancel?: () => void, + canDetach?: boolean, // Called after editor is created to set up editor cleanup (e.g. saving on click-away). setupCleanup: ( - owner: MultiHolder, + owner: Disposable, doc: GristDoc, editingFormula: ko.Computed, save: () => Promise ) => void, -}): Disposable { +}): FormulaEditor { const {gristDoc, editRow, refElem, setupCleanup} = options; - const holder = MultiHolder.create(null); + const attachedHolder = new MultiHolder(); + + if (options.field) { + options.column = options.field.origCol(); + } else if (options.canDetach) { + throw new Error('Field is required for detached editor'); + } + + // We can't rely on the field passed in, we need to create our own. const column = options.column ?? options.field?.column(); if (!column) { - throw new Error(t('Column or field is required')); + throw new Error('Column or field is required'); } // AsyncOnce ensures it's called once even if triggered multiple times. const saveEdit = asyncOnce(async () => { + const detached = editor.isDetached.get(); + if (detached) { + editor.dispose(); + return; + } const formula = String(editor.getCellValue()); if (formula !== column.formula.peek()) { if (options.onSave) { @@ -310,9 +453,9 @@ export function openFormulaEditor(options: { } else { await column.updateColValues({formula}); } - holder.dispose(); + editor.dispose(); } else { - holder.dispose(); + editor.dispose(); options.onCancel?.(); } }); @@ -321,23 +464,31 @@ export function openFormulaEditor(options: { const editCommands = { fieldEditSave: () => { saveEdit().catch(reportError); }, fieldEditSaveHere: () => { saveEdit().catch(reportError); }, - fieldEditCancel: () => { holder.dispose(); options.onCancel?.(); }, + fieldEditCancel: () => { editor.dispose(); options.onCancel?.(); }, }; - // Replace the item in the Holder with a new one, disposing the previous one. - const editor = FormulaEditor.create(holder, { + const formulaError = editRow ? getFormulaError(attachedHolder, { gristDoc, + editRow, column, + field: options.field, + }) : undefined; + const editor = FormulaEditor.create(null, { + gristDoc, + column, + field: options.field, editingFormula: options.editingFormula, rowId: editRow ? editRow.id() : 0, cellValue: column.formula(), - formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined, + formulaError, editValue: options.editValue, cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor. commands: editCommands, cssClass: 'formula_editor_sidepane', - readonly : false - } as IFormulaEditorOptions); + readonly : false, + canDetach: options.canDetach + } as IFormulaEditorOptions) as FormulaEditor; + editor.autoDispose(attachedHolder); editor.attach(refElem); const editingFormula = options.editingFormula ?? options?.field?.editingFormula; @@ -353,30 +504,92 @@ export function openFormulaEditor(options: { if (!column.formula()) { editingFormula(true); } - setupCleanup(holder, gristDoc, editingFormula, saveEdit); - return holder; + setupCleanup(editor, gristDoc, editingFormula, saveEdit); + return editor; } /** * If the cell at the given row and column is a formula value containing an exception, return an * observable with this exception, and fetch more details to add to the observable. */ -export function getFormulaError( - gristDoc: GristDoc, editRow: DataRowModel, column: ColumnRec -): Observable|undefined { - const colId = column.colId.peek(); - const cellCurrentValue = editRow.cells[colId].peek(); - const isFormula = column.isFormula() || column.hasTriggerFormula(); - if (isFormula && isRaisedException(cellCurrentValue)) { - const formulaError = Observable.create(null, cellCurrentValue); - gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId()) - .then(value => { - formulaError.set(value); - }) - .catch(reportError); +export function getFormulaError(owner: Disposable, options: { + gristDoc: GristDoc, + editRow: DataRowModel, + column?: ColumnRec, + field?: ViewFieldRec, +}): Observable { + const {gristDoc, editRow} = options; + const formulaError = Observable.create(owner, undefined as any); + // When we don't have a field information we don't need to be reactive at all. + if (!options.field) { + const column = options.column!; + const colId = column.colId.peek(); + const onValueChange = errorMonitor(gristDoc, column, editRow, owner, formulaError); + const subscription = editRow.cells[colId].subscribe(onValueChange); + owner.autoDispose(subscription); + onValueChange(editRow.cells[colId].peek()); return formulaError; + } else { + + // We can't rely on the editRow we got, as this is owned by the view. When we will be detached the view will be + // gone. So, we will create our own observable that will be updated when the row is updated. + const errorRow: DataRowModel = gristDoc.getTableModel(options.field.tableId.peek()).createFloatingRowModel() as any; + errorRow.assign(editRow.getRowId()); + owner.autoDispose(errorRow); + + // When we have a field information we will grab the error from the column that is currently connected to the field. + // This will change when user is using the preview feature in detached editor, where a new column is created, and + // field starts showing it instead of the original column. + Computed.create(owner, use => { + // This pattern creates a subscription using compute observable. + + // Create an holder for everything that is created during recomputation. It will be returned as the value + // of the computed observable, and will be disposed when the value changes. + const holder = MultiHolder.create(use.owner); + + // Now subscribe to the column in the field, this is the part that will be changed when user creates a preview. + const column = use(options.field!.column); + const colId = use(column.colId); + const onValueChange = errorMonitor(gristDoc, column, errorRow, holder, formulaError); + // Unsubscribe when computed is recomputed. + holder.autoDispose(errorRow.cells[colId].subscribe(onValueChange)); + // Trigger the subscription to get the initial value. + onValueChange(errorRow.cells[colId].peek()); + + // Return the holder, it will be disposed when the value changes. + return holder; + }); } - return undefined; + return formulaError; +} + +function errorMonitor( + gristDoc: GristDoc, + column: ColumnRec, + editRow: DataRowModel, + holder: Disposable, + formulaError: Observable ) { + return function onValueChange(cellCurrentValue: CellValue) { + const isFormula = column.isFormula() || column.hasTriggerFormula(); + if (isFormula && isRaisedException(cellCurrentValue)) { + if (!formulaError.get()) { + // Don't update it when there is already an error (to avoid flickering). + formulaError.set(cellCurrentValue); + } + gristDoc.docData.getFormulaError(column.table().tableId(), column.colId(), editRow.getRowId()) + .then(value => { + if (holder.isDisposed()) { return; } + formulaError.set(value); + }) + .catch((er) => { + if (!holder.isDisposed()) { + reportError(er); + } + }); + } else { + formulaError.set(undefined); + } + }; } /** @@ -429,8 +642,44 @@ export function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, or const cssCollapseIcon = styled(icon, ` margin: -3px 4px 0 4px; --icon-color: ${colors.slate}; + cursor: pointer; `); export const cssError = styled('div', ` color: ${theme.errorText}; `); + +const cssFormulaEditor = styled('div.default_editor.formula_editor_wrapper', ` + &-detached { + height: 100%; + position: relative; + box-shadow: none; + } + &-detached .formula_editor { + flex-grow: 1; + } + + &-detached .error_msg, &-detached .error_details { + flex-grow: 0; + flex-shrink: 1; + cursor: default; + } + + &-detached .code_editor_container { + height: 100%; + width: 100%; + } + + &-detached .ace_editor { + height: 100% !important; + width: 100% !important; + } + + .floating-popup .formula_editor { + min-height: 100px; + } + + .floating-popup .error_details { + min-height: 100px; + } +`); diff --git a/app/client/widgets/NTextEditor.ts b/app/client/widgets/NTextEditor.ts index f6ab9936..8a6cb445 100644 --- a/app/client/widgets/NTextEditor.ts +++ b/app/client/widgets/NTextEditor.ts @@ -33,7 +33,7 @@ export class NTextEditor extends NewBaseEditor { options.editValue, String(options.cellValue ?? "")); this.editorState = Observable.create(this, initialValue); - this.commandGroup = this.autoDispose(createGroup(options.commands, null, true)); + this.commandGroup = this.autoDispose(createGroup(options.commands, this, true)); this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left'; this._dom = dom('div.default_editor', diff --git a/app/client/widgets/NewBaseEditor.ts b/app/client/widgets/NewBaseEditor.ts index 9df916c0..178d61dd 100644 --- a/app/client/widgets/NewBaseEditor.ts +++ b/app/client/widgets/NewBaseEditor.ts @@ -17,7 +17,7 @@ export interface Options { gristDoc: GristDoc; cellValue: CellValue; rowId: number; - formulaError?: Observable; + formulaError: Observable; editValue?: string; cursorPos: number; commands: IEditorCommandGroup; @@ -83,6 +83,11 @@ export abstract class NewBaseEditor extends Disposable { */ public abstract attach(cellElem: Element): void; + /** + * Called to detach the editor and show it in the floating popup. + */ + public detach(): HTMLElement|null { return null; } + /** * Returns DOM container with the editor, typically present and attached after attach() has been * called. diff --git a/app/common/Prefs.ts b/app/common/Prefs.ts index d19e1ec5..7d418941 100644 --- a/app/common/Prefs.ts +++ b/app/common/Prefs.ts @@ -104,6 +104,8 @@ export const DismissedPopup = StringUnion( 'deleteRecords', // confirmation for deleting records keyboard shortcut, 'deleteFields', // confirmation for deleting columns keyboard shortcut, 'tutorialFirstCard', // first card of the tutorial, + 'formulaHelpInfo', // formula help info shown in the popup editor, + 'formulaAssistantInfo', // formula assistant info shown in the popup editor, ); export type DismissedPopup = typeof DismissedPopup.type; diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index da8725f9..173a120b 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -74,6 +74,9 @@ export const commonUrls = { plans: "https://www.getgrist.com/pricing", sproutsProgram: "https://www.getgrist.com/sprouts-program", contact: "https://www.getgrist.com/contact", + community: 'https://community.getgrist.com', + functions: 'https://support.getgrist.com/functions', + formulaSheet: 'https://support.getgrist.com/formula-cheat-sheet', basicTutorial: 'https://templates.getgrist.com/woXtXUBmiN5T/Grist-Basics', basicTutorialImage: 'https://www.getgrist.com/wp-content/uploads/2021/08/lightweight-crm.png', diff --git a/app/server/lib/Assistance.ts b/app/server/lib/Assistance.ts index 9d55dd7f..f008d5f3 100644 --- a/app/server/lib/Assistance.ts +++ b/app/server/lib/Assistance.ts @@ -237,10 +237,74 @@ export class HuggingFaceAssistant implements Assistant { } } +/** + * Test assistant that mimics ChatGPT and just returns the input. + */ +export class EchoAssistant implements Assistant { + public async apply(doc: AssistanceDoc, request: AssistanceRequest): Promise { + const messages = request.state?.messages || []; + if (messages.length === 0) { + messages.push({ + role: 'system', + content: '' + }); + messages.push({ + role: 'user', content: request.text, + }); + } else { + if (request.regenerate) { + if (messages[messages.length - 1].role !== 'user') { + messages.pop(); + } + } + messages.push({ + role: 'user', content: request.text, + }); + } + let completion = request.text; + const reply = completion; + const history = { messages }; + history.messages.push({ + role: 'assistant', + content: completion, + }); + // This model likes returning markdown. Code will typically + // be in a code block with ``` delimiters. + let lines = completion.split('\n'); + if (lines[0].startsWith('```')) { + lines.shift(); + completion = lines.join('\n'); + const parts = completion.split('```'); + if (parts.length > 1) { + completion = parts[0]; + } + lines = completion.split('\n'); + } + // This model likes repeating the function signature and + // docstring, so we try to strip that out. + completion = lines.join('\n'); + while (completion.includes('"""')) { + const parts = completion.split('"""'); + completion = parts[parts.length - 1]; + } + + // If there's no code block, don't treat the answer as a formula. + if (!reply.includes('```')) { + completion = ''; + } + const response = await completionToResponse(doc, request, completion, reply); + response.state = history; + return response; + } +} + /** * Instantiate an assistant, based on environment variables. */ function getAssistant() { + if (process.env.OPENAI_API_KEY === 'test') { + return new EchoAssistant(); + } if (process.env.OPENAI_API_KEY) { return new OpenAIAssistant(); } diff --git a/static/icons/icons.css b/static/icons/icons.css index 8f98ccf0..f998d832 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -114,6 +114,7 @@ --icon-ResizePanel: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSI0IiB5PSIyIiB3aWR0aD0iMiIgaGVpZ2h0PSIxMiIgcng9IjEiLz48L3N2Zz4='); --icon-Revert: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuMTIzMTcgMy4wMDMzTDMuMDg3MjggNi45MTEwM0w2LjgxMzM2IDUuNjY0MzYiIHN0cm9rZT0iIzE2QjM3OCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTEzLjYxNTggMTAuMDc5OUMxNC4wMzY0IDguNjg0NzggMTMuODg1NSA3LjE3OTc0IDEzLjE5NjUgNS44OTU4NiAxMi41MDc0IDQuNjExOTggMTEuMzM2NSAzLjY1NDQzIDkuOTQxMzcgMy4yMzM4NSA4LjU0NjI3IDIuODEzMjcgNy4wNDEyMyAyLjk2NDExIDUuNzU3MzUgMy42NTMyIDQuNDczNDcgNC4zNDIzIDMuNTE1OTIgNS41MTMxOCAzLjA5NTM0IDYuOTA4MjhNNi40Mjk0MyAxMy41ODc2QzUuODQ4OCAxMy4zNjgzIDUuMzA5NjIgMTMuMDUyIDQuODM0ODQgMTIuNjUyMk0xMC4wMzk4IDEzLjY4MTZDOS40OTk1MyAxMy44NTM0IDguOTM1OTUgMTMuOTQwNyA4LjM2OTAzIDEzLjk0MDJIOC4yNDMwNE0xMi44ODY4IDExLjU3MjhDMTIuNTQ4MiAxMi4wNjA4IDEyLjEzMzIgMTIuNDkxMiAxMS42NTc3IDEyLjg0NzNNMy42NjIxNSAxMS4yODA0QzMuMzUxNDggMTAuNzY2NiAzLjEyNzc2IDEwLjIwNTEgMyA5LjYxODQxIiBzdHJva2U9IiMxNkIzNzgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg=='); --icon-RightAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE04LjUsMTMgQzguMjIzODU3NjMsMTMgOCwxMi43NzYxNDI0IDgsMTIuNSBDOCwxMi4yMjM4NTc2IDguMjIzODU3NjMsMTIgOC41LDEyIEwxMy41LDEyIEMxMy43NzYxNDI0LDEyIDE0LDEyLjIyMzg1NzYgMTQsMTIuNSBDMTQsMTIuNzc2MTQyNCAxMy43NzYxNDI0LDEzIDEzLjUsMTMgTDguNSwxMyBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); + --icon-Robot: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE2IDJWMEgxMFYySDEyVjRINFYySDZWMEgwVjJIMlY0SDBWMTZIMTZWNEgxNFYySDE2Wk0xMCA3QzEwLjYgNyAxMSA3LjQgMTEgOEMxMSA4LjYgMTAuNiA5IDEwIDlDOS40IDkgOSA4LjYgOSA4QzkgNy40IDkuNCA3IDEwIDdaTTYgN0M2LjYgNyA3IDcuNCA3IDhDNyA4LjYgNi42IDkgNiA5QzUuNCA5IDUgOC42IDUgOEM1IDcuNCA1LjQgNyA2IDdaTTEyIDEzSDRWMTFIMTJWMTNaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); --icon-Script: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48cmVjdCB3aWR0aD0iMTkuMzI4IiBoZWlnaHQ9IjE5LjMyOCIgeD0iMi4zMzYiIHk9IjIuMzM2IiByeT0iNC4wMjYiIHJ4PSIzLjc0NSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PGcgc3Ryb2tlLXdpZHRoPSIxLjU0MyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciI+PHBhdGggZD0iTTE4LjczNDQ0OSA5LjAyMTI1NTZMNS4yNjU1NTEyIDkuMDA5NTM2OE0xOC43MzQ0NDkgMTQuNzM4NTg2TDUuMjY1NTUxMiAxNC43MjY4NjciIHRyYW5zZm9ybT0ibWF0cml4KC45Mzk2OSAwIDAgMS4wMDUzMSAuNzI0IC0uMDYzKSIvPjwvZz48L3N2Zz4='); --icon-Search: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjQzNjIxMjcsMTAuNzI5MTA1OSBMMTQuODUzNTUzNCwxNC4xNDY0NDY2IEMxNS4wNDg4MTU1LDE0LjM0MTcwODggMTUuMDQ4ODE1NSwxNC42NTgyOTEyIDE0Ljg1MzU1MzQsMTQuODUzNTUzNCBDMTQuNjU4MjkxMiwxNS4wNDg4MTU1IDE0LjM0MTcwODgsMTUuMDQ4ODE1NSAxNC4xNDY0NDY2LDE0Ljg1MzU1MzQgTDEwLjcyOTEwNTksMTEuNDM2MjEyNyBDOS41OTIzMzg0OCwxMi40MTEwNDg3IDguMTE0OTQ3NzEsMTMgNi41LDEzIEMyLjkxMDE0OTEzLDEzIDAsMTAuMDg5ODUwOSAwLDYuNSBDMCwyLjkxMDE0OTEzIDIuOTEwMTQ5MTMsMCA2LjUsMCBDMTAuMDg5ODUwOSwwIDEzLDIuOTEwMTQ5MTMgMTMsNi41IEMxMyw4LjExNDk0NzcxIDEyLjQxMTA0ODcsOS41OTIzMzg0OCAxMS40MzYyMTI3LDEwLjcyOTEwNTkgWiBNMTAuNDA5NTc0NywxMC4zNjg0OTIxIEMxMS4zOTI4MzI1LDkuMzc0ODU3OCAxMiw4LjAwODMzNDY4IDEyLDYuNSBDMTIsMy40NjI0MzM4OCA5LjUzNzU2NjEyLDEgNi41LDEgQzMuNDYyNDMzODgsMSAxLDMuNDYyNDMzODggMSw2LjUgQzEsOS41Mzc1NjYxMiAzLjQ2MjQzMzg4LDEyIDYuNSwxMiBDOC4wMDgzMzQ2OCwxMiA5LjM3NDg1NzgsMTEuMzkyODMyNSAxMC4zNjg0OTIxLDEwLjQwOTU3NDcgQzEwLjM3NDkwMDEsMTAuNDAyMzg3OSAxMC4zODE1NTE2LDEwLjM5NTM0MTYgMTAuMzg4NDQ2NiwxMC4zODg0NDY2IEMxMC4zOTUzNDE2LDEwLjM4MTU1MTYgMTAuNDAyMzg3OSwxMC4zNzQ5MDAxIDEwLjQwOTU3NDcsMTAuMzY4NDkyMSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); --icon-Settings: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuOTgwNDcxOSwzLjI3MzM2NTEyIEM0LjgxNDE0NDY1LDIuNTc4NDA2ODQgNS44NTY2MjI5MSwyLjEyNTQ1Njc0IDcsMi4wMjI0MjE1MSBMNywwLjUgQzcsMC4yMjM4NTc2MjUgNy4yMjM4NTc2MywwIDcuNSwwIEM3Ljc3NjE0MjM3LDAgOCwwLjIyMzg1NzYyNSA4LDAuNSBMOCwyLjAyMjQyMTUxIEM5LjE0MzM3NzA5LDIuMTI1NDU2NzQgMTAuMTg1ODU1NCwyLjU3ODQwNjg0IDExLjAxOTUyODEsMy4yNzMzNjUxMiBMMTIuMDk2NDQ2NiwyLjE5NjQ0NjYxIEMxMi4yOTE3MDg4LDIuMDAxMTg0NDYgMTIuNjA4MjkxMiwyLjAwMTE4NDQ2IDEyLjgwMzU1MzQsMi4xOTY0NDY2MSBDMTIuOTk4ODE1NSwyLjM5MTcwODc2IDEyLjk5ODgxNTUsMi43MDgyOTEyNCAxMi44MDM1NTM0LDIuOTAzNTUzMzkgTDExLjcyNjYzNDksMy45ODA0NzE5IEMxMi40MjE1OTMyLDQuODE0MTQ0NjUgMTIuODc0NTQzMyw1Ljg1NjYyMjkxIDEyLjk3NzU3ODUsNyBMMTQuNSw3IEMxNC43NzYxNDI0LDcgMTUsNy4yMjM4NTc2MyAxNSw3LjUgQzE1LDcuNzc2MTQyMzcgMTQuNzc2MTQyNCw4IDE0LjUsOCBMMTIuOTc3NTc4NSw4IEMxMi44NzQ1NDMzLDkuMTQzMzc3MDkgMTIuNDIxNTkzMiwxMC4xODU4NTU0IDExLjcyNjYzNDksMTEuMDE5NTI4MSBMMTIuODAzNTUzNCwxMi4wOTY0NDY2IEMxMi45OTg4MTU1LDEyLjI5MTcwODggMTIuOTk4ODE1NSwxMi42MDgyOTEyIDEyLjgwMzU1MzQsMTIuODAzNTUzNCBDMTIuNjA4MjkxMiwxMi45OTg4MTU1IDEyLjI5MTcwODgsMTIuOTk4ODE1NSAxMi4wOTY0NDY2LDEyLjgwMzU1MzQgTDExLjAxOTUyODEsMTEuNzI2NjM0OSBDMTAuMTg1ODU1NCwxMi40MjE1OTMyIDkuMTQzMzc3MDksMTIuODc0NTQzMyA4LDEyLjk3NzU3ODUgTDgsMTQuNSBDOCwxNC43NzYxNDI0IDcuNzc2MTQyMzcsMTUgNy41LDE1IEM3LjIyMzg1NzYzLDE1IDcsMTQuNzc2MTQyNCA3LDE0LjUgTDcsMTIuOTc3NTc4NSBDNS44NTY2MjI5MSwxMi44NzQ1NDMzIDQuODE0MTQ0NjUsMTIuNDIxNTkzMiAzLjk4MDQ3MTksMTEuNzI2NjM0OSBMMi45MDM1NTMzOSwxMi44MDM1NTM0IEMyLjcwODI5MTI0LDEyLjk5ODgxNTUgMi4zOTE3MDg3NiwxMi45OTg4MTU1IDIuMTk2NDQ2NjEsMTIuODAzNTUzNCBDMi4wMDExODQ0NiwxMi42MDgyOTEyIDIuMDAxMTg0NDYsMTIuMjkxNzA4OCAyLjE5NjQ0NjYxLDEyLjA5NjQ0NjYgTDMuMjczMzY1MTIsMTEuMDE5NTI4MSBDMi41Nzg0MDY4NCwxMC4xODU4NTU0IDIuMTI1NDU2NzQsOS4xNDMzNzcwOSAyLjAyMjQyMTUxLDggTDAuNSw4IEMwLjIyMzg1NzYyNSw4IDAsNy43NzYxNDIzNyAwLDcuNSBDMCw3LjIyMzg1NzYzIDAuMjIzODU3NjI1LDcgMC41LDcgTDIuMDIyNDIxNTEsNyBDMi4xMjU0NTY3NCw1Ljg1NjYyMjkxIDIuNTc4NDA2ODQsNC44MTQxNDQ2NSAzLjI3MzM2NTEyLDMuOTgwNDcxOSBMMi4xOTY0NDY2MSwyLjkwMzU1MzM5IEMyLjAwMTE4NDQ2LDIuNzA4MjkxMjQgMi4wMDExODQ0NiwyLjM5MTcwODc2IDIuMTk2NDQ2NjEsMi4xOTY0NDY2MSBDMi4zOTE3MDg3NiwyLjAwMTE4NDQ2IDIuNzA4MjkxMjQsMi4wMDExODQ0NiAyLjkwMzU1MzM5LDIuMTk2NDQ2NjEgTDMuOTgwNDcxOSwzLjI3MzM2NTEyIFogTTcuNSwxMCBDNi4xMTkyODgxMywxMCA1LDguODgwNzExODcgNSw3LjUgQzUsNi4xMTkyODgxMyA2LjExOTI4ODEzLDUgNy41LDUgQzguODgwNzExODcsNSAxMCw2LjExOTI4ODEzIDEwLDcuNSBDMTAsOC44ODA3MTE4NyA4Ljg4MDcxMTg3LDEwIDcuNSwxMCBaIE03LjUsOSBDOC4zMjg0MjcxMiw5IDksOC4zMjg0MjcxMiA5LDcuNSBDOSw2LjY3MTU3Mjg4IDguMzI4NDI3MTIsNiA3LjUsNiBDNi42NzE1NzI4OCw2IDYsNi42NzE1NzI4OCA2LDcuNSBDNiw4LjMyODQyNzEyIDYuNjcxNTcyODgsOSA3LjUsOSBaIE03LjUsMTIgQzkuOTg1MjgxMzcsMTIgMTIsOS45ODUyODEzNyAxMiw3LjUgQzEyLDUuMDE0NzE4NjMgOS45ODUyODEzNywzIDcuNSwzIEM1LjAxNDcxODYzLDMgMyw1LjAxNDcxODYzIDMsNy41IEMzLDkuOTg1MjgxMzcgNS4wMTQ3MTg2MywxMiA3LjUsMTIgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); diff --git a/static/ui-icons/UI/Robot.svg b/static/ui-icons/UI/Robot.svg new file mode 100644 index 00000000..bf2c4464 --- /dev/null +++ b/static/ui-icons/UI/Robot.svg @@ -0,0 +1,9 @@ + + + + Icons / UI / Robot + Created with Sketch. + + + + \ No newline at end of file diff --git a/test/nbrowser/DocTutorial.ts b/test/nbrowser/DocTutorial.ts index 5701d3eb..8c5a6f7f 100644 --- a/test/nbrowser/DocTutorial.ts +++ b/test/nbrowser/DocTutorial.ts @@ -74,7 +74,7 @@ describe('DocTutorial', function () { await gu.skipWelcomeQuestions(); // Make sure we have clean start. - await driver.executeScript('resetSeenPopups();'); + await driver.executeScript('resetDismissedPopups();'); await gu.waitForServer(); await driver.navigate().refresh(); await gu.waitForDocMenuToLoad(); diff --git a/test/nbrowser/RightPanel.ts b/test/nbrowser/RightPanel.ts index 93a65f93..f7d16459 100644 --- a/test/nbrowser/RightPanel.ts +++ b/test/nbrowser/RightPanel.ts @@ -13,7 +13,7 @@ describe('RightPanel', function() { await mainSession.tempNewDoc(cleanup); // Reset prefs. - await driver.executeScript('resetSeenPopups();'); + await driver.executeScript('resetDismissedPopups();'); await gu.waitForServer(); // Refresh for a clean start. diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 651236bf..ed44cb4e 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -960,9 +960,15 @@ export async function waitForServer(optTimeout: number = 2000) { * Sends UserActions using client api from the browser. */ export async function sendActions(actions: UserAction[]) { - await driver.executeScript(` - gristDocPageModel.gristDoc.get().docModel.docData.sendActions(${JSON.stringify(actions)}); + const result = await driver.executeAsyncScript(` + const done = arguments[arguments.length - 1]; + const prom = gristDocPageModel.gristDoc.get().docModel.docData.sendActions(${JSON.stringify(actions)}); + prom.then(() => done(null)); + prom.catch((err) => done(String(err?.message || err))); `); + if (result) { + throw new Error(result as string); + } await waitForServer(); } diff --git a/test/server/testUtils.ts b/test/server/testUtils.ts index dac5978e..fa73770b 100644 --- a/test/server/testUtils.ts +++ b/test/server/testUtils.ts @@ -303,6 +303,10 @@ export class EnvironmentSnapshot { } } } + + public get(key: string): string|undefined { + return this._oldEnv[key]; + } } export async function getBuildFile(relativePath: string): Promise {