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>
     `;
   }