webui: mv scroll behavior to sketch-timeline
Moves the automatic scrolling behavior from sketch-app-shell into
sketch-timeline, where it makes more sense.
This change also makes automatic scrolling conditional on
an internal "scrollingState", which we update whenever you
manually scroll to, or away from the bottom of the timeline.
If you scroll to the bottom of the timeline, then it's "sticky"
and newly arriving messages will keep scrolling you to the bottom
as they render.
If you scroll up to older messages though, then we stop automatically
scrolling you to the latest messages at the bottom of the timeline.
When the timeline is in this latter "floating" scrollingState, we
also render a floating down-arrow button over the lower right corner
of the timeline. Clicking on this will take you down to the latest
message at the end of the timeline.
diff --git a/loop/webui/package-lock.json b/loop/webui/package-lock.json
index 211bf1b..e4b2748 100644
--- a/loop/webui/package-lock.json
+++ b/loop/webui/package-lock.json
@@ -20,6 +20,7 @@
"vega-lite": "^5.23.0"
},
"devDependencies": {
+ "@open-wc/dev-server-hmr": "^0.1.2-next.0",
"@sand4rt/experimental-ct-web": "^1.51.1",
"@types/marked": "^5.0.2",
"@types/mocha": "^10.0.7",
@@ -33,6 +34,20 @@
"typescript": "^5.8.2"
}
},
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@@ -47,6 +62,153 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/compat-data": {
+ "version": "7.26.8",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz",
+ "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz",
+ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.2.0",
+ "@babel/code-frame": "^7.26.2",
+ "@babel/generator": "^7.26.10",
+ "@babel/helper-compilation-targets": "^7.26.5",
+ "@babel/helper-module-transforms": "^7.26.0",
+ "@babel/helpers": "^7.26.10",
+ "@babel/parser": "^7.26.10",
+ "@babel/template": "^7.26.9",
+ "@babel/traverse": "^7.26.10",
+ "@babel/types": "^7.26.10",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/core/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz",
+ "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.27.0",
+ "@babel/types": "^7.27.0",
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz",
+ "integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.26.8",
+ "@babel/helper-validator-option": "^7.25.9",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz",
+ "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.25.9",
+ "@babel/types": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz",
+ "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.25.9",
+ "@babel/helper-validator-identifier": "^7.25.9",
+ "@babel/traverse": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.26.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz",
+ "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
+ "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-validator-identifier": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
@@ -56,6 +218,139 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz",
+ "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
+ "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.27.0",
+ "@babel/types": "^7.27.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
+ "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.27.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-class-properties": {
+ "version": "7.12.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+ "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.12.13"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-assertions": {
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz",
+ "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-top-level-await": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
+ "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
+ "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.26.2",
+ "@babel/parser": "^7.27.0",
+ "@babel/types": "^7.27.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz",
+ "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.26.2",
+ "@babel/generator": "^7.27.0",
+ "@babel/parser": "^7.27.0",
+ "@babel/template": "^7.27.0",
+ "@babel/types": "^7.27.0",
+ "debug": "^4.3.1",
+ "globals": "^11.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
+ "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.25.9",
+ "@babel/helper-validator-identifier": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
@@ -487,6 +782,21 @@
"integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==",
"dev": true
},
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.8",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
+ "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/set-array": "^1.2.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -497,6 +807,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
+ "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
@@ -568,6 +888,99 @@
"node": ">= 8"
}
},
+ "node_modules/@open-wc/dev-server-hmr": {
+ "version": "0.1.2-next.0",
+ "resolved": "https://registry.npmjs.org/@open-wc/dev-server-hmr/-/dev-server-hmr-0.1.2-next.0.tgz",
+ "integrity": "sha512-XgazcRuYE0J17X1LgZ/BumwMf81p7qR1h3ncc3ljA3PDqXIBSOYnt1SSoR/IqlJvDmbTopONYQ/w+qjEOtBrAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.12.3",
+ "@babel/plugin-syntax-class-properties": "^7.12.13",
+ "@babel/plugin-syntax-import-assertions": "^7.12.1",
+ "@babel/plugin-syntax-top-level-await": "^7.12.1",
+ "@web/dev-server-core": "^0.3.10",
+ "@web/dev-server-hmr": "^0.1.6",
+ "picomatch": "^2.2.2"
+ }
+ },
+ "node_modules/@open-wc/dev-server-hmr/node_modules/@web/dev-server-core": {
+ "version": "0.3.19",
+ "resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.3.19.tgz",
+ "integrity": "sha512-Q/Xt4RMVebLWvALofz1C0KvP8qHbzU1EmdIA2Y1WMPJwiFJFhPxdr75p9YxK32P2t0hGs6aqqS5zE0HW9wYzYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/koa": "^2.11.6",
+ "@types/ws": "^7.4.0",
+ "@web/parse5-utils": "^1.2.0",
+ "chokidar": "^3.4.3",
+ "clone": "^2.1.2",
+ "es-module-lexer": "^1.0.0",
+ "get-stream": "^6.0.0",
+ "is-stream": "^2.0.0",
+ "isbinaryfile": "^4.0.6",
+ "koa": "^2.13.0",
+ "koa-etag": "^4.0.0",
+ "koa-send": "^5.0.1",
+ "koa-static": "^5.0.0",
+ "lru-cache": "^6.0.0",
+ "mime-types": "^2.1.27",
+ "parse5": "^6.0.1",
+ "picomatch": "^2.2.2",
+ "ws": "^7.4.2"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/@open-wc/dev-server-hmr/node_modules/@web/parse5-utils": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-1.3.1.tgz",
+ "integrity": "sha512-haCgDchZrAOB9EhBJ5XqiIjBMsS/exsM5Ru7sCSyNkXVEJWskyyKuKMFk66BonnIGMPpDtqDrTUfYEis5Zi3XA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/parse5": "^6.0.1",
+ "parse5": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/@open-wc/dev-server-hmr/node_modules/isbinaryfile": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz",
+ "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/gjtorikian/"
+ }
+ },
+ "node_modules/@open-wc/dev-server-hmr/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@open-wc/dev-server-hmr/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/@playwright/experimental-ct-core": {
"version": "1.51.1",
"resolved": "https://registry.npmjs.org/@playwright/experimental-ct-core/-/experimental-ct-core-1.51.1.tgz",
@@ -1343,6 +1756,83 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@web/dev-server-hmr": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/@web/dev-server-hmr/-/dev-server-hmr-0.1.12.tgz",
+ "integrity": "sha512-oqEYVFAh9D74GUigQqxPN5izhocc+A02tZ7Y4QCIHLe6qttjD5R+Hpj8CAObySslfH1X/IGSsWhB8TGctCxlPA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@web/dev-server-core": "^0.4.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/@web/dev-server-hmr/node_modules/@web/dev-server-core": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.4.1.tgz",
+ "integrity": "sha512-KdYwejXZwIZvb6tYMCqU7yBiEOPfKLQ3V9ezqqEz8DA9V9R3oQWaowckvCpFB9IxxPfS/P8/59OkdzGKQjcIUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/koa": "^2.11.6",
+ "@types/ws": "^7.4.0",
+ "@web/parse5-utils": "^1.3.1",
+ "chokidar": "^3.4.3",
+ "clone": "^2.1.2",
+ "es-module-lexer": "^1.0.0",
+ "get-stream": "^6.0.0",
+ "is-stream": "^2.0.0",
+ "isbinaryfile": "^5.0.0",
+ "koa": "^2.13.0",
+ "koa-etag": "^4.0.0",
+ "koa-send": "^5.0.1",
+ "koa-static": "^5.0.0",
+ "lru-cache": "^6.0.0",
+ "mime-types": "^2.1.27",
+ "parse5": "^6.0.1",
+ "picomatch": "^2.2.2",
+ "ws": "^7.4.2"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/@web/dev-server-hmr/node_modules/@web/parse5-utils": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-1.3.1.tgz",
+ "integrity": "sha512-haCgDchZrAOB9EhBJ5XqiIjBMsS/exsM5Ru7sCSyNkXVEJWskyyKuKMFk66BonnIGMPpDtqDrTUfYEis5Zi3XA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/parse5": "^6.0.1",
+ "parse5": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/@web/dev-server-hmr/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@web/dev-server-hmr/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/@web/dev-server-rollup": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/@web/dev-server-rollup/-/dev-server-rollup-0.6.4.tgz",
@@ -1781,6 +2271,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -1987,6 +2491,19 @@
"node": ">=10.0.0"
}
},
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -2206,6 +2723,31 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
"node_modules/chrome-launcher": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz",
@@ -3437,6 +3979,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -3522,6 +4074,16 @@
"node": ">= 6"
}
},
+ "node_modules/globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/globby": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
@@ -3870,6 +4432,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -4110,6 +4685,19 @@
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
"dev": true
},
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
@@ -4123,6 +4711,19 @@
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
@@ -4390,6 +4991,16 @@
"node": ">=8"
}
},
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@@ -4607,6 +5218,16 @@
"node": "*"
}
},
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
@@ -5294,6 +5915,19 @@
"node": ">= 0.8"
}
},
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -6766,6 +7400,13 @@
"node": ">=10"
}
},
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
diff --git a/loop/webui/package.json b/loop/webui/package.json
index 33f599a..15a5e92 100644
--- a/loop/webui/package.json
+++ b/loop/webui/package.json
@@ -29,6 +29,7 @@
"vega-lite": "^5.23.0"
},
"devDependencies": {
+ "@open-wc/dev-server-hmr": "^0.1.2-next.0",
"@sand4rt/experimental-ct-web": "^1.51.1",
"@types/marked": "^5.0.2",
"@types/mocha": "^10.0.7",
diff --git a/loop/webui/src/web-components/demo/sketch-timeline.demo.html b/loop/webui/src/web-components/demo/sketch-timeline.demo.html
index f8b7ad4..be8ab8e 100644
--- a/loop/webui/src/web-components/demo/sketch-timeline.demo.html
+++ b/loop/webui/src/web-components/demo/sketch-timeline.demo.html
@@ -6,7 +6,6 @@
src="/dist/web-components/sketch-timeline.js"
type="module"
></script>
-
<script>
const messages = [
{
@@ -33,16 +32,121 @@
type: "user",
content: "a user message",
},
+ {
+ type: "agent",
+ content: "an agent message",
+ },
+ {
+ type: "user",
+ content: "a user message",
+ },
+ {
+ type: "tool",
+ content: "a tool use message",
+ },
+ {
+ type: "commit",
+ end_of_turn: false,
+ content: "",
+ commits: [
+ {
+ hash: "ece101c103ec231da87f4df05c1b5e6a24e13add",
+ subject: "Add README.md for web components directory",
+ body: "This adds documentation for the web components used in the Loop UI,\nincluding a description of each component, usage examples, and\ndevelopment guidelines.\n\nCo-Authored-By: sketch\nadd README.md for loop/webui/src/web-components",
+ pushed_branch:
+ "sketch/create-readmemd-for-web-components-directory",
+ },
+ ],
+ timestamp: "2025-04-14T16:39:33.639533919Z",
+ conversation_id: "",
+ idx: 17,
+ },
+ {
+ type: "agent",
+ content: "an end-of-turn agent message",
+ end_of_turn: true,
+ },
];
+
document.addEventListener("DOMContentLoaded", () => {
+ const appShell = document.querySelector(".app-shell");
const timelineEl = document.querySelector("sketch-timeline");
timelineEl.messages = messages;
+ timelineEl.scrollContainer = appShell;
+ const addMessagesCheckbox = document.querySelector("#addMessages");
+ addMessagesCheckbox.addEventListener("change", toggleAddMessages);
+
+ let addingMessages = false;
+ const addNewMessagesInterval = 1000;
+
+ function addNewMessages() {
+ if (!addingMessages) {
+ return;
+ }
+ const n = new Date().getMilliseconds() % messages.length;
+ const msgToDup = messages[n];
+ const dup = JSON.parse(JSON.stringify(msgToDup));
+ dup.idx = messages.length;
+ dup.timestamp = new Date().toISOString();
+ messages.push(dup);
+ timelineEl.messages = messages.concat();
+ timelineEl.prop;
+ timelineEl.requestUpdate();
+ }
+
+ let addMessagesHandler = setInterval(
+ addNewMessages,
+ addNewMessagesInterval,
+ );
+
+ function toggleAddMessages() {
+ addingMessages = !addingMessages;
+ if (addingMessages) {
+ } else {
+ }
+ }
});
</script>
+ <style>
+ .app-shell {
+ display: block;
+ font-family:
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+ color: rgb(51, 51, 51);
+ line-height: 1.4;
+ min-height: 100vh;
+ width: 100%;
+ position: relative;
+ overflow-x: hidden;
+ }
+ .app-header {
+ flex-grow: 0;
+ }
+ .view-container {
+ flex-grow: 2;
+ }
+ </style>
</head>
<body>
- <h1>sketch-timeline demo</h1>
-
- <sketch-timeline></sketch-timeline>
+ <div class="app-shell">
+ <div class="app-header">
+ <h1>sketch-timeline demo</h1>
+ <input
+ type="checkbox"
+ id="addMessages"
+ title="Automatically add new messages"
+ /><label for="addMessages">Automatically add new messages</label>
+ </div>
+ <div class="view-container">
+ <div class="chat-view view-active">
+ <sketch-timeline></sketch-timeline>
+ </div>
+ </div>
+ </div>
</body>
</html>
diff --git a/loop/webui/src/web-components/sketch-app-shell.ts b/loop/webui/src/web-components/sketch-app-shell.ts
index 4dcb251..62bcd03 100644
--- a/loop/webui/src/web-components/sketch-app-shell.ts
+++ b/loop/webui/src/web-components/sketch-app-shell.ts
@@ -1,6 +1,5 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
-import { PropertyValues } from "lit";
import { DataManager, ConnectionStatus } from "../data";
import { State, TimelineMessage, ToolCall } from "../types";
import "./sketch-container-status";
@@ -193,10 +192,6 @@
@state()
private isFirstLoad: boolean = true;
- // Track if we should scroll to the bottom
- @state()
- private shouldScrollToBottom: boolean = true;
-
// Mutation observer to detect when new messages are added
private mutationObserver: MutationObserver | null = null;
@@ -520,13 +515,10 @@
if (isFirstFetch) {
console.log("Auto-scroll: First data fetch, will scroll to bottom");
this.isFirstLoad = true;
- this.shouldScrollToBottom = true;
this.messageStatus = "Initial messages loaded";
} else if (newMessages && newMessages.length > 0) {
console.log(`Auto-scroll: Received ${newMessages.length} new messages`);
this.messageStatus = "Updated just now";
- // Check if we should scroll before updating messages
- this.shouldScrollToBottom = this.checkShouldScroll();
} else {
this.messageStatus = "No new messages";
}
@@ -546,7 +538,7 @@
// Log information about the message update
if (this.messages.length > oldMessageCount) {
console.log(
- `Auto-scroll: Messages updated from ${oldMessageCount} to ${this.messages.length}, shouldScroll=${this.shouldScrollToBottom}`,
+ `Auto-scroll: Messages updated from ${oldMessageCount} to ${this.messages.length}`,
);
}
}
@@ -588,10 +580,6 @@
(this.dataManager as any).nextFetchIndex = 0;
(this.dataManager as any).currentFetchStartIndex = 0;
- // Always scroll to bottom after sending a message
- console.log("Auto-scroll: User sent a message, forcing scroll to bottom");
- this.shouldScrollToBottom = true;
-
// // If in diff view, switch to conversation view
// if (this.viewMode === "diff") {
// await this.toggleViewMode("chat");
@@ -599,30 +587,6 @@
// Refresh the timeline data to show the new message
await this.dataManager.fetchData();
-
- // Force multiple scroll attempts to ensure the user message is visible
- // This addresses potential timing issues with DOM updates
- const forceScrollAttempts = () => {
- console.log("Auto-scroll: Forcing scroll after user message");
- this.shouldScrollToBottom = true;
-
- // Update the timeline component's scroll state
- const timeline = this.shadowRoot?.querySelector(
- "sketch-timeline",
- ) as any;
- if (timeline && timeline.setShouldScrollToLatest) {
- timeline.setShouldScrollToLatest(true);
- timeline.scrollToLatest();
- } else {
- this.scrollToBottom();
- }
- };
-
- // Make multiple scroll attempts with different timings
- // This ensures we catch the DOM after various update stages
- setTimeout(forceScrollAttempts, 100);
- setTimeout(forceScrollAttempts, 300);
- setTimeout(forceScrollAttempts, 600);
} catch (error) {
console.error("Error sending chat message:", error);
const statusText = document.getElementById("statusText");
@@ -666,7 +630,10 @@
<div class="view-container">
<div class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}">
- <sketch-timeline .messages=${this.messages}></sketch-timeline>
+ <sketch-timeline
+ .messages=${this.messages}
+ .scrollContainer=${this}
+ ></sketch-timeline>
</div>
<div class="diff-view ${this.viewMode === "diff" ? "view-active" : ""}">
@@ -698,48 +665,6 @@
}
/**
- * Check if the page should scroll to the bottom based on current view position
- * @returns Boolean indicating if we should scroll to the bottom
- */
- private checkShouldScroll(): boolean {
- // If we're not in chat view, don't auto-scroll
- if (this.viewMode !== "chat") {
- return false;
- }
-
- // More generous threshold - if we're within 500px of the bottom, auto-scroll
- // This ensures we start scrolling sooner when new messages appear
- const scrollPosition = window.scrollY;
- const windowHeight = window.innerHeight;
- const documentHeight = document.body.scrollHeight;
- const distanceFromBottom = documentHeight - (scrollPosition + windowHeight);
- const threshold = 500; // Increased threshold to be more responsive
-
- return distanceFromBottom <= threshold;
- }
-
- /**
- * Scroll to the bottom of the timeline
- */
- private scrollToBottom(): void {
- if (!this.checkShouldScroll()) {
- return;
- }
-
- this.scrollTo({ top: this.scrollHeight, behavior: "smooth" });
- }
-
- /**
- * Called after the component's properties have been updated
- */
- updated(changedProperties: PropertyValues): void {
- // If messages have changed, scroll to bottom if needed
- if (changedProperties.has("messages") && this.messages.length > 0) {
- setTimeout(() => this.scrollToBottom(), 50);
- }
- }
-
- /**
* Lifecycle callback when component is first connected to DOM
*/
firstUpdated(): void {
diff --git a/loop/webui/src/web-components/sketch-timeline.ts b/loop/webui/src/web-components/sketch-timeline.ts
index 7471ded..7deeb97 100644
--- a/loop/webui/src/web-components/sketch-timeline.ts
+++ b/loop/webui/src/web-components/sketch-timeline.ts
@@ -1,6 +1,7 @@
import { css, html, LitElement } from "lit";
+import { PropertyValues } from "lit";
import { repeat } from "lit/directives/repeat.js";
-import { customElement, property } from "lit/decorators.js";
+import { customElement, property, state } from "lit/decorators.js";
import { State, TimelineMessage } from "../types";
import "./sketch-timeline-message";
@@ -9,10 +10,13 @@
@property()
messages: TimelineMessage[] = [];
- // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
- // Note that these styles only apply to the scope of this web component's
- // shadow DOM node, so they won't leak out or collide with CSS declared in
- // other components or the containing web page (...unless you want it to do that).
+ // Track if we should scroll to the bottom
+ @state()
+ private scrollingState: "pinToLatest" | "floating" = "pinToLatest";
+
+ @property()
+ scrollContainer: HTMLDivElement;
+
static styles = css`
/* Hide views initially to prevent flash of content */
.timeline-container .timeline,
@@ -57,6 +61,31 @@
.timeline.empty::before {
display: none;
}
+
+ #scroll-container {
+ overflow: auto;
+ padding-left: 1em;
+ }
+ #jump-to-latest {
+ display: none;
+ position: fixed;
+ bottom: 100px;
+ right: 0;
+ background: rgb(33, 150, 243);
+ color: white;
+ border-radius: 8px;
+ padding: 0.5em;
+ margin: 0.5em;
+ font-size: x-large;
+ opacity: 0.5;
+ cursor: pointer;
+ }
+ #jump-to-latest:hover {
+ opacity: 1;
+ }
+ #jump-to-latest.floating {
+ display: block;
+ }
`;
constructor() {
@@ -64,6 +93,32 @@
// Binding methods
this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
+ this._handleScroll = this._handleScroll.bind(this);
+ }
+
+ /**
+ * Scroll to the bottom of the timeline
+ */
+ private scrollToBottom(): void {
+ this.scrollContainer?.scrollTo({
+ top: this.scrollContainer?.scrollHeight,
+ behavior: "smooth",
+ });
+ }
+
+ /**
+ * Called after the component's properties have been updated
+ */
+ updated(changedProperties: PropertyValues): void {
+ // If messages have changed, scroll to bottom if needed
+ if (changedProperties.has("messages") && this.messages.length > 0) {
+ if (this.scrollingState == "pinToLatest") {
+ setTimeout(() => this.scrollToBottom(), 50);
+ }
+ }
+ if (changedProperties.has("scrollContainer")) {
+ this.scrollContainer?.addEventListener("scroll", this._handleScroll);
+ }
}
/**
@@ -82,6 +137,21 @@
}
}
+ private _handleScroll(event) {
+ const isAtBottom =
+ Math.abs(
+ this.scrollContainer.scrollHeight -
+ this.scrollContainer.clientHeight -
+ this.scrollContainer.scrollTop,
+ ) <= 1;
+ if (isAtBottom) {
+ this.scrollingState = "pinToLatest";
+ } else {
+ // TODO: does scroll direction matter here?
+ this.scrollingState = "floating";
+ }
+ }
+
// See https://lit.dev/docs/components/lifecycle/
connectedCallback() {
super.connectedCallback();
@@ -91,6 +161,7 @@
"showCommitDiff",
this._handleShowCommitDiff as EventListener,
);
+ this.scrollContainer?.addEventListener("scroll", this._handleScroll);
}
// See https://lit.dev/docs/components/lifecycle/
@@ -102,8 +173,12 @@
"showCommitDiff",
this._handleShowCommitDiff as EventListener,
);
+
+ this.scrollContainer?.removeEventListener("scroll", this._handleScroll);
}
+ // messageKey uniquely identifes a TimelineMessage based on its ID and tool_calls, so
+ // that we only re-render <sketch-message> elements that we need to re-render.
messageKey(message: TimelineMessage): string {
// If the message has tool calls, and any of the tool_calls get a response, we need to
// re-render that message.
@@ -116,17 +191,26 @@
render() {
return html`
- <div class="timeline-container">
- ${repeat(this.messages, this.messageKey, (message, index) => {
- let previousMessage: TimelineMessage;
- if (index > 0) {
- previousMessage = this.messages[index - 1];
- }
- return html`<sketch-timeline-message
- .message=${message}
- .previousMessage=${previousMessage}
- ></sketch-timeline-message>`;
- })}
+ <div id="scroll-container">
+ <div class="timeline-container">
+ ${repeat(this.messages, this.messageKey, (message, index) => {
+ let previousMessage: TimelineMessage;
+ if (index > 0) {
+ previousMessage = this.messages[index - 1];
+ }
+ return html`<sketch-timeline-message
+ .message=${message}
+ .previousMessage=${previousMessage}
+ ></sketch-timeline-message>`;
+ })}
+ </div>
+ </div>
+ <div
+ id="jump-to-latest"
+ class="${this.scrollingState}"
+ @click=${this.scrollToBottom}
+ >
+ ⇩
</div>
`;
}