Initial commit
diff --git a/loop/webui/src/diff2.css b/loop/webui/src/diff2.css
new file mode 100644
index 0000000..5a7ad71
--- /dev/null
+++ b/loop/webui/src/diff2.css
@@ -0,0 +1,142 @@
+/* Custom styles for diff2 view */
+
+/* Override container max-width for diff2 view */
+#diff2View .diff-container {
+ max-width: 100%;
+ width: 100%;
+}
+
+/* When diff2 view is active, allow container to expand to full width */
+.container.diff2-active,
+.timeline-container.diff-active {
+ max-width: 100%;
+ padding-left: 20px;
+ padding-right: 20px;
+}
+
+/* Fix line-height inheritance issue */
+.d2h-code-line,
+.d2h-code-line-ctn,
+.d2h-code-linenumber {
+ line-height: 1.4 !important;
+}
+
+/* Make diff2 file container use the full width */
+.d2h-file-wrapper {
+ width: 100%;
+ margin-bottom: 20px;
+}
+
+/* Make side-by-side view use the full width */
+.d2h-file-side-diff {
+ width: 50% !important;
+}
+
+/* Style for diff lines - for both side-by-side and unified views */
+.d2h-code-line,
+.d2h-code-side-line {
+ transition: background-color 0.2s;
+ position: relative;
+}
+
+.d2h-code-line:hover,
+.d2h-code-side-line:hover {
+ background-color: #e6f7ff !important;
+}
+
+/* Plus button styles for commenting */
+.d2h-gutter-comment-button {
+ display: none;
+ position: absolute;
+ right: 0; /* Adjusted from -11px to prevent layout shifts */
+ top: 50%;
+ transform: translateY(-50%);
+ width: 22px;
+ height: 22px;
+ background-color: #0366d6;
+ color: white;
+ border-radius: 50%;
+ text-align: center;
+ line-height: 20px;
+ font-size: 16px;
+ font-weight: bold;
+ cursor: pointer;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+ opacity: 0.9;
+ z-index: 100;
+ user-select: none;
+}
+
+.d2h-gutter-comment-button:hover {
+ background-color: #0256bd;
+ opacity: 1;
+}
+
+/* Show the plus button on row hover (including line number and code) and when hovering over the button itself */
+tr:hover .d2h-gutter-comment-button,
+.d2h-gutter-comment-button:hover {
+ display: block;
+}
+
+/* Ensure diff2html content uses all available space */
+.diff2html-content {
+ width: 100%;
+ overflow-x: auto;
+}
+
+/* Diff view controls */
+#diff-view-controls {
+ display: flex;
+ justify-content: flex-end;
+ padding: 10px;
+ background-color: #f5f5f5;
+ border-bottom: 1px solid #ddd;
+}
+
+.diff-view-format {
+ display: flex;
+ gap: 15px;
+}
+
+.diff-view-format label {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ cursor: pointer;
+ font-size: 14px;
+ user-select: none;
+}
+
+.diff-view-format input[type="radio"] {
+ margin: 0;
+ cursor: pointer;
+}
+
+/* Adjust code line padding to make room for the gutter button */
+.d2h-code-line-ctn {
+ position: relative;
+ padding-left: 14px !important;
+}
+
+/* Ensure gutter is wide enough for the plus button */
+.d2h-code-linenumber,
+.d2h-code-side-linenumber {
+ position: relative;
+ min-width: 60px !important; /* Increased from 45px to accommodate 3-digit line numbers plus button */
+ padding-right: 15px !important; /* Ensure space for the button */
+ overflow: visible !important; /* Prevent button from being clipped */
+ text-align: right; /* Ensure consistent text alignment */
+ box-sizing: border-box; /* Ensure padding is included in width calculation */
+}
+
+/* Ensure table rows and cells don't clip the button */
+.d2h-diff-table tr,
+.d2h-diff-table td {
+ overflow: visible !important;
+}
+
+/* Add a bit of padding between line number and code content for visual separation */
+.d2h-code-line-ctn,
+.d2h-code-side-line-ctn {
+ padding-left: 8px !important;
+}
diff --git a/loop/webui/src/diff2html.min.css b/loop/webui/src/diff2html.min.css
new file mode 100644
index 0000000..8014a13
--- /dev/null
+++ b/loop/webui/src/diff2html.min.css
@@ -0,0 +1 @@
+:host,:root{--d2h-bg-color:#fff;--d2h-border-color:#ddd;--d2h-dim-color:rgba(0,0,0,.3);--d2h-line-border-color:#eee;--d2h-file-header-bg-color:#f7f7f7;--d2h-file-header-border-color:#d8d8d8;--d2h-empty-placeholder-bg-color:#f1f1f1;--d2h-empty-placeholder-border-color:#e1e1e1;--d2h-selected-color:#c8e1ff;--d2h-ins-bg-color:#dfd;--d2h-ins-border-color:#b4e2b4;--d2h-ins-highlight-bg-color:#97f295;--d2h-ins-label-color:#399839;--d2h-del-bg-color:#fee8e9;--d2h-del-border-color:#e9aeae;--d2h-del-highlight-bg-color:#ffb6ba;--d2h-del-label-color:#c33;--d2h-change-del-color:#fdf2d0;--d2h-change-ins-color:#ded;--d2h-info-bg-color:#f8fafd;--d2h-info-border-color:#d5e4f2;--d2h-change-label-color:#d0b44c;--d2h-moved-label-color:#3572b0;--d2h-dark-color:#e6edf3;--d2h-dark-bg-color:#0d1117;--d2h-dark-border-color:#30363d;--d2h-dark-dim-color:#6e7681;--d2h-dark-line-border-color:#21262d;--d2h-dark-file-header-bg-color:#161b22;--d2h-dark-file-header-border-color:#30363d;--d2h-dark-empty-placeholder-bg-color:hsla(215,8%,47%,.1);--d2h-dark-empty-placeholder-border-color:#30363d;--d2h-dark-selected-color:rgba(56,139,253,.1);--d2h-dark-ins-bg-color:rgba(46,160,67,.15);--d2h-dark-ins-border-color:rgba(46,160,67,.4);--d2h-dark-ins-highlight-bg-color:rgba(46,160,67,.4);--d2h-dark-ins-label-color:#3fb950;--d2h-dark-del-bg-color:rgba(248,81,73,.1);--d2h-dark-del-border-color:rgba(248,81,73,.4);--d2h-dark-del-highlight-bg-color:rgba(248,81,73,.4);--d2h-dark-del-label-color:#f85149;--d2h-dark-change-del-color:rgba(210,153,34,.2);--d2h-dark-change-ins-color:rgba(46,160,67,.25);--d2h-dark-info-bg-color:rgba(56,139,253,.1);--d2h-dark-info-border-color:rgba(56,139,253,.4);--d2h-dark-change-label-color:#d29922;--d2h-dark-moved-label-color:#3572b0}.d2h-wrapper{text-align:left}.d2h-file-header{background-color:#f7f7f7;background-color:var(--d2h-file-header-bg-color);border-bottom:1px solid #d8d8d8;border-bottom:1px solid var(--d2h-file-header-border-color);display:-webkit-box;display:-ms-flexbox;display:flex;font-family:Source Sans Pro,Helvetica Neue,Helvetica,Arial,sans-serif;height:35px;padding:5px 10px}.d2h-file-header.d2h-sticky-header{position:sticky;top:0;z-index:1}.d2h-file-stats{display:-webkit-box;display:-ms-flexbox;display:flex;font-size:14px;margin-left:auto}.d2h-lines-added{border:1px solid #b4e2b4;border:1px solid var(--d2h-ins-border-color);border-radius:5px 0 0 5px;color:#399839;color:var(--d2h-ins-label-color);padding:2px;text-align:right;vertical-align:middle}.d2h-lines-deleted{border:1px solid #e9aeae;border:1px solid var(--d2h-del-border-color);border-radius:0 5px 5px 0;color:#c33;color:var(--d2h-del-label-color);margin-left:1px;padding:2px;text-align:left;vertical-align:middle}.d2h-file-name-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;font-size:15px;width:100%}.d2h-file-name{overflow-x:hidden;text-overflow:ellipsis;white-space:nowrap}.d2h-file-wrapper{border:1px solid #ddd;border:1px solid var(--d2h-border-color);border-radius:3px;margin-bottom:1em}.d2h-file-collapse{-webkit-box-pack:end;-ms-flex-pack:end;cursor:pointer;display:none;font-size:12px;justify-content:flex-end;-webkit-box-align:center;-ms-flex-align:center;align-items:center;border:1px solid #ddd;border:1px solid var(--d2h-border-color);border-radius:3px;padding:4px 8px}.d2h-file-collapse.d2h-selected{background-color:#c8e1ff;background-color:var(--d2h-selected-color)}.d2h-file-collapse-input{margin:0 4px 0 0}.d2h-diff-table{border-collapse:collapse;font-family:Menlo,Consolas,monospace;font-size:13px;width:100%}.d2h-files-diff{display:-webkit-box;display:-ms-flexbox;display:flex;width:100%}.d2h-file-diff{overflow-y:hidden}.d2h-file-diff.d2h-d-none,.d2h-files-diff.d2h-d-none{display:none}.d2h-file-side-diff{display:inline-block;overflow-x:scroll;overflow-y:hidden;width:50%}.d2h-code-line{padding:0 8em;width:calc(100% - 16em)}.d2h-code-line,.d2h-code-side-line{display:inline-block;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;white-space:nowrap}.d2h-code-side-line{padding:0 4.5em;width:calc(100% - 9em)}.d2h-code-line-ctn{background:none;display:inline-block;padding:0;word-wrap:normal;-webkit-user-select:text;-moz-user-select:text;-ms-user-select:text;user-select:text;vertical-align:middle;white-space:pre;width:100%}.d2h-code-line del,.d2h-code-side-line del{background-color:#ffb6ba;background-color:var(--d2h-del-highlight-bg-color)}.d2h-code-line del,.d2h-code-line ins,.d2h-code-side-line del,.d2h-code-side-line ins{border-radius:.2em;display:inline-block;margin-top:-1px;-webkit-text-decoration:none;text-decoration:none}.d2h-code-line ins,.d2h-code-side-line ins{background-color:#97f295;background-color:var(--d2h-ins-highlight-bg-color);text-align:left}.d2h-code-line-prefix{background:none;display:inline;padding:0;word-wrap:normal;white-space:pre}.line-num1{float:left}.line-num1,.line-num2{-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;padding:0 .5em;text-overflow:ellipsis;width:3.5em}.line-num2{float:right}.d2h-code-linenumber{background-color:#fff;background-color:var(--d2h-bg-color);border:solid #eee;border:solid var(--d2h-line-border-color);border-width:0 1px;-webkit-box-sizing:border-box;box-sizing:border-box;color:rgba(0,0,0,.3);color:var(--d2h-dim-color);cursor:pointer;display:inline-block;position:absolute;text-align:right;width:7.5em}.d2h-code-linenumber:after{content:"\200b"}.d2h-code-side-linenumber{background-color:#fff;background-color:var(--d2h-bg-color);border:solid #eee;border:solid var(--d2h-line-border-color);border-width:0 1px;-webkit-box-sizing:border-box;box-sizing:border-box;color:rgba(0,0,0,.3);color:var(--d2h-dim-color);cursor:pointer;display:inline-block;overflow:hidden;padding:0 .5em;position:absolute;text-align:right;text-overflow:ellipsis;width:4em}.d2h-code-side-linenumber:after{content:"\200b"}.d2h-code-side-emptyplaceholder,.d2h-emptyplaceholder{background-color:#f1f1f1;background-color:var(--d2h-empty-placeholder-bg-color);border-color:#e1e1e1;border-color:var(--d2h-empty-placeholder-border-color)}.d2h-code-line-prefix,.d2h-code-linenumber,.d2h-code-side-linenumber,.d2h-emptyplaceholder{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.d2h-code-linenumber,.d2h-code-side-linenumber{direction:rtl}.d2h-del{background-color:#fee8e9;background-color:var(--d2h-del-bg-color);border-color:#e9aeae;border-color:var(--d2h-del-border-color)}.d2h-ins{background-color:#dfd;background-color:var(--d2h-ins-bg-color);border-color:#b4e2b4;border-color:var(--d2h-ins-border-color)}.d2h-info{background-color:#f8fafd;background-color:var(--d2h-info-bg-color);border-color:#d5e4f2;border-color:var(--d2h-info-border-color);color:rgba(0,0,0,.3);color:var(--d2h-dim-color)}.d2h-file-diff .d2h-del.d2h-change{background-color:#fdf2d0;background-color:var(--d2h-change-del-color)}.d2h-file-diff .d2h-ins.d2h-change{background-color:#ded;background-color:var(--d2h-change-ins-color)}.d2h-file-list-wrapper{margin-bottom:10px}.d2h-file-list-wrapper a{-webkit-text-decoration:none;text-decoration:none}.d2h-file-list-wrapper a,.d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-moved-label-color)}.d2h-file-list-header{text-align:left}.d2h-file-list-title{font-weight:700}.d2h-file-list-line{display:-webkit-box;display:-ms-flexbox;display:flex;text-align:left}.d2h-file-list{display:block;list-style:none;margin:0;padding:0}.d2h-file-list>li{border-bottom:1px solid #ddd;border-bottom:1px solid var(--d2h-border-color);margin:0;padding:5px 10px}.d2h-file-list>li:last-child{border-bottom:none}.d2h-file-switch{cursor:pointer;display:none;font-size:10px}.d2h-icon{margin-right:10px;vertical-align:middle;fill:currentColor}.d2h-deleted{color:#c33;color:var(--d2h-del-label-color)}.d2h-added{color:#399839;color:var(--d2h-ins-label-color)}.d2h-changed{color:#d0b44c;color:var(--d2h-change-label-color)}.d2h-moved{color:#3572b0;color:var(--d2h-moved-label-color)}.d2h-tag{background-color:#fff;background-color:var(--d2h-bg-color);display:-webkit-box;display:-ms-flexbox;display:flex;font-size:10px;margin-left:5px;padding:0 2px}.d2h-deleted-tag{border:1px solid #c33;border:1px solid var(--d2h-del-label-color)}.d2h-added-tag{border:1px solid #399839;border:1px solid var(--d2h-ins-label-color)}.d2h-changed-tag{border:1px solid #d0b44c;border:1px solid var(--d2h-change-label-color)}.d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-moved-label-color)}.d2h-dark-color-scheme{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);color:#e6edf3;color:var(--d2h-dark-color)}.d2h-dark-color-scheme .d2h-file-header{background-color:#161b22;background-color:var(--d2h-dark-file-header-bg-color);border-bottom:#30363d;border-bottom:var(--d2h-dark-file-header-border-color)}.d2h-dark-color-scheme .d2h-lines-added{border:1px solid rgba(46,160,67,.4);border:1px solid var(--d2h-dark-ins-border-color);color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-lines-deleted{border:1px solid rgba(248,81,73,.4);border:1px solid var(--d2h-dark-del-border-color);color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-code-line del,.d2h-dark-color-scheme .d2h-code-side-line del{background-color:rgba(248,81,73,.4);background-color:var(--d2h-dark-del-highlight-bg-color)}.d2h-dark-color-scheme .d2h-code-line ins,.d2h-dark-color-scheme .d2h-code-side-line ins{background-color:rgba(46,160,67,.4);background-color:var(--d2h-dark-ins-highlight-bg-color)}.d2h-dark-color-scheme .d2h-diff-tbody{border-color:#30363d;border-color:var(--d2h-dark-border-color)}.d2h-dark-color-scheme .d2h-code-side-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,.d2h-dark-color-scheme .d2h-files-diff .d2h-emptyplaceholder{background-color:hsla(215,8%,47%,.1);background-color:var(--d2h-dark-empty-placeholder-bg-color);border-color:#30363d;border-color:var(--d2h-dark-empty-placeholder-border-color)}.d2h-dark-color-scheme .d2h-code-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-del{background-color:rgba(248,81,73,.1);background-color:var(--d2h-dark-del-bg-color);border-color:rgba(248,81,73,.4);border-color:var(--d2h-dark-del-border-color)}.d2h-dark-color-scheme .d2h-ins{background-color:rgba(46,160,67,.15);background-color:var(--d2h-dark-ins-bg-color);border-color:rgba(46,160,67,.4);border-color:var(--d2h-dark-ins-border-color)}.d2h-dark-color-scheme .d2h-info{background-color:rgba(56,139,253,.1);background-color:var(--d2h-dark-info-bg-color);border-color:rgba(56,139,253,.4);border-color:var(--d2h-dark-info-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-file-diff .d2h-del.d2h-change{background-color:rgba(210,153,34,.2);background-color:var(--d2h-dark-change-del-color)}.d2h-dark-color-scheme .d2h-file-diff .d2h-ins.d2h-change{background-color:rgba(46,160,67,.25);background-color:var(--d2h-dark-change-ins-color)}.d2h-dark-color-scheme .d2h-file-wrapper{border:1px solid #30363d;border:1px solid var(--d2h-dark-border-color)}.d2h-dark-color-scheme .d2h-file-collapse{border:1px solid #0d1117;border:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-file-collapse.d2h-selected{background-color:rgba(56,139,253,.1);background-color:var(--d2h-dark-selected-color)}.d2h-dark-color-scheme .d2h-file-list-wrapper a,.d2h-dark-color-scheme .d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-dark-color-scheme .d2h-file-list>li{border-bottom:1px solid #0d1117;border-bottom:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted{color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-added{color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-changed{color:#d29922;color:var(--d2h-dark-change-label-color)}.d2h-dark-color-scheme .d2h-moved{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-dark-color-scheme .d2h-tag{background-color:#0d1117;background-color:var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted-tag{border:1px solid #f85149;border:1px solid var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-added-tag{border:1px solid #3fb950;border:1px solid var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-changed-tag{border:1px solid #d29922;border:1px solid var(--d2h-dark-change-label-color)}.d2h-dark-color-scheme .d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-dark-moved-label-color)}@media (prefers-color-scheme:dark){.d2h-auto-color-scheme{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);color:#e6edf3;color:var(--d2h-dark-color)}.d2h-auto-color-scheme .d2h-file-header{background-color:#161b22;background-color:var(--d2h-dark-file-header-bg-color);border-bottom:#30363d;border-bottom:var(--d2h-dark-file-header-border-color)}.d2h-auto-color-scheme .d2h-lines-added{border:1px solid rgba(46,160,67,.4);border:1px solid var(--d2h-dark-ins-border-color);color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-lines-deleted{border:1px solid rgba(248,81,73,.4);border:1px solid var(--d2h-dark-del-border-color);color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-code-line del,.d2h-auto-color-scheme .d2h-code-side-line del{background-color:rgba(248,81,73,.4);background-color:var(--d2h-dark-del-highlight-bg-color)}.d2h-auto-color-scheme .d2h-code-line ins,.d2h-auto-color-scheme .d2h-code-side-line ins{background-color:rgba(46,160,67,.4);background-color:var(--d2h-dark-ins-highlight-bg-color)}.d2h-auto-color-scheme .d2h-diff-tbody{border-color:#30363d;border-color:var(--d2h-dark-border-color)}.d2h-auto-color-scheme .d2h-code-side-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,.d2h-auto-color-scheme .d2h-files-diff .d2h-emptyplaceholder{background-color:hsla(215,8%,47%,.1);background-color:var(--d2h-dark-empty-placeholder-bg-color);border-color:#30363d;border-color:var(--d2h-dark-empty-placeholder-border-color)}.d2h-auto-color-scheme .d2h-code-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-del{background-color:rgba(248,81,73,.1);background-color:var(--d2h-dark-del-bg-color);border-color:rgba(248,81,73,.4);border-color:var(--d2h-dark-del-border-color)}.d2h-auto-color-scheme .d2h-ins{background-color:rgba(46,160,67,.15);background-color:var(--d2h-dark-ins-bg-color);border-color:rgba(46,160,67,.4);border-color:var(--d2h-dark-ins-border-color)}.d2h-auto-color-scheme .d2h-info{background-color:rgba(56,139,253,.1);background-color:var(--d2h-dark-info-bg-color);border-color:rgba(56,139,253,.4);border-color:var(--d2h-dark-info-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-file-diff .d2h-del.d2h-change{background-color:rgba(210,153,34,.2);background-color:var(--d2h-dark-change-del-color)}.d2h-auto-color-scheme .d2h-file-diff .d2h-ins.d2h-change{background-color:rgba(46,160,67,.25);background-color:var(--d2h-dark-change-ins-color)}.d2h-auto-color-scheme .d2h-file-wrapper{border:1px solid #30363d;border:1px solid var(--d2h-dark-border-color)}.d2h-auto-color-scheme .d2h-file-collapse{border:1px solid #0d1117;border:1px solid var(--d2h-dark-bg-color)}.d2h-auto-color-scheme .d2h-file-collapse.d2h-selected{background-color:rgba(56,139,253,.1);background-color:var(--d2h-dark-selected-color)}.d2h-auto-color-scheme .d2h-file-list-wrapper a,.d2h-auto-color-scheme .d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-auto-color-scheme .d2h-file-list>li{border-bottom:1px solid #0d1117;border-bottom:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted{color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-added{color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-changed{color:#d29922;color:var(--d2h-dark-change-label-color)}.d2h-auto-color-scheme .d2h-moved{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-auto-color-scheme .d2h-tag{background-color:#0d1117;background-color:var(--d2h-dark-bg-color)}.d2h-auto-color-scheme .d2h-deleted-tag{border:1px solid #f85149;border:1px solid var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-added-tag{border:1px solid #3fb950;border:1px solid var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-changed-tag{border:1px solid #d29922;border:1px solid var(--d2h-dark-change-label-color)}.d2h-auto-color-scheme .d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-dark-moved-label-color)}}
\ No newline at end of file
diff --git a/loop/webui/src/index.html b/loop/webui/src/index.html
new file mode 100644
index 0000000..a1f62a0
--- /dev/null
+++ b/loop/webui/src/index.html
@@ -0,0 +1,40 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Loop WebUI</title>
+ <link rel="stylesheet" href="tailwind.css" />
+ <style>
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
+ Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
+ margin: 0;
+ padding: 20px;
+ background-color: #f5f5f5;
+ }
+ #app {
+ max-width: 800px;
+ margin: 0 auto;
+ background-color: white;
+ border-radius: 8px;
+ padding: 20px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ }
+ h1 {
+ color: #333;
+ }
+ #status {
+ margin-top: 20px;
+ padding: 10px;
+ background-color: #e8f5e9;
+ border-radius: 4px;
+ color: #2e7d32;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="app">Loading...</div>
+ <script src="index.js"></script>
+ </body>
+</html>
diff --git a/loop/webui/src/input.css b/loop/webui/src/input.css
new file mode 100644
index 0000000..176b454
--- /dev/null
+++ b/loop/webui/src/input.css
@@ -0,0 +1,5 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Custom styles can be added below */
diff --git a/loop/webui/src/timeline.css b/loop/webui/src/timeline.css
new file mode 100644
index 0000000..2928c44
--- /dev/null
+++ b/loop/webui/src/timeline.css
@@ -0,0 +1,1306 @@
+body {
+ font-family:
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+ margin: 0;
+ padding: 20px;
+ padding-top: 80px; /* Added padding to account for the fixed top banner */
+ padding-bottom: 100px; /* Adjusted padding for chat container */
+ color: #333;
+ line-height: 1.4; /* Reduced line height for more compact text */
+}
+
+.timeline-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ position: relative;
+}
+
+/* When diff view is active, allow timeline container to expand to full width */
+.timeline-container.diff-active {
+ max-width: 100%;
+}
+
+/* Top banner with combined elements */
+.top-banner {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 5px 20px;
+ margin-bottom: 0;
+ border-bottom: 1px solid #eee;
+ flex-wrap: wrap;
+ gap: 10px;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ background: white;
+ z-index: 100;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ max-width: 100%;
+}
+
+.banner-title {
+ font-size: 18px;
+ font-weight: 600;
+ margin: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.chat-title {
+ margin: 0;
+ padding: 0;
+ color: rgba(82, 82, 82, 0.85);
+ font-size: 16px;
+ font-weight: normal;
+ font-style: italic;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+}
+
+/* Original header styles kept for compatibility */
+header {
+ display: none; /* Hidden since we're using top-banner instead */
+}
+
+/* Ensure the container starts below the fixed top banner */
+.timeline-container {
+ padding-top: 10px;
+}
+
+h1 {
+ margin: 0;
+ font-size: 24px;
+ font-weight: 600;
+}
+
+.info-card {
+ background: #f9f9f9;
+ border-radius: 8px;
+ padding: 15px;
+ margin-bottom: 20px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+ display: none; /* Hidden in the combined layout */
+}
+
+.info-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ background: #f9f9f9;
+ border-radius: 4px;
+ padding: 4px 10px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+ flex: 1;
+}
+
+.info-item {
+ display: flex;
+ align-items: center;
+ white-space: nowrap;
+ margin-right: 10px;
+ font-size: 13px;
+}
+
+.info-label {
+ font-size: 11px;
+ color: #555;
+ margin-right: 3px;
+ font-weight: 500;
+}
+
+.info-value {
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.cost {
+ color: #2e7d32;
+}
+
+.refresh-control {
+ display: flex;
+ align-items: center;
+ margin-bottom: 0;
+ flex-wrap: nowrap;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.refresh-button {
+ background: #4caf50;
+ color: white;
+ border: none;
+ padding: 4px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ margin: 5px;
+}
+
+.poll-updates {
+ display: flex;
+ align-items: center;
+ margin: 0 5px;
+ font-size: 12px;
+}
+
+.status-container {
+ display: flex;
+ align-items: center;
+}
+
+.polling-indicator {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-right: 4px;
+ background-color: #ccc;
+}
+
+.polling-indicator.active {
+ background-color: #4caf50;
+ animation: pulse 1.5s infinite;
+}
+
+.polling-indicator.error {
+ background-color: #f44336;
+ animation: pulse 1.5s infinite;
+}
+
+@keyframes pulse {
+ 0% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+
+.status-text {
+ font-size: 11px;
+ color: #666;
+}
+
+/* Timeline styles that should remain unchanged */
+.timeline {
+ position: relative;
+ margin: 10px 0;
+ scroll-behavior: smooth;
+}
+
+.timeline::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 15px;
+ width: 2px;
+ background: #e0e0e0;
+ border-radius: 1px;
+}
+
+/* Hide the timeline vertical line when there are no messages */
+.timeline.empty::before {
+ display: none;
+}
+
+.message {
+ position: relative;
+ margin-bottom: 5px;
+ padding-left: 30px;
+}
+
+.message-icon {
+ position: absolute;
+ left: 10px;
+ top: 0;
+ transform: translateX(-50%);
+ width: 16px;
+ height: 16px;
+ border-radius: 3px;
+ text-align: center;
+ line-height: 16px;
+ color: #fff;
+ font-size: 10px;
+}
+
+.message-content {
+ position: relative;
+ padding: 5px 10px;
+ background: #fff;
+ border-radius: 3px;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ border-left: 3px solid transparent;
+}
+
+/* Removed arrow decoration for a more compact look */
+
+.message-header {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ margin-bottom: 3px;
+ font-size: 12px;
+}
+
+.message-timestamp {
+ font-size: 10px;
+ color: #888;
+ font-style: italic;
+ margin-left: 3px;
+}
+
+.conversation-id {
+ font-family: monospace;
+ font-size: 12px;
+ padding: 2px 4px;
+ background-color: #f0f0f0;
+ border-radius: 3px;
+ margin-left: auto;
+}
+
+.parent-info {
+ font-size: 11px;
+ opacity: 0.8;
+}
+
+.subconversation {
+ border-left: 2px solid transparent;
+ padding-left: 5px;
+ margin-left: 20px;
+ transition: margin-left 0.3s ease;
+}
+
+.message-text {
+ overflow-x: auto;
+ margin-bottom: 3px;
+ font-family: monospace;
+ padding: 3px 5px;
+ background: #f7f7f7;
+ border-radius: 2px;
+ user-select: text;
+ cursor: text;
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+ font-size: 13px;
+ line-height: 1.3;
+}
+
+.tool-details {
+ margin-top: 3px;
+ padding-top: 3px;
+ border-top: 1px dashed #e0e0e0;
+ font-size: 12px;
+}
+
+.tool-name {
+ font-size: 12px;
+ font-weight: bold;
+ margin-bottom: 2px;
+ background: #f0f0f0;
+ padding: 2px 4px;
+ border-radius: 2px;
+ display: flex;
+ align-items: center;
+ gap: 3px;
+}
+
+.tool-input,
+.tool-result {
+ margin-top: 2px;
+ padding: 3px 5px;
+ background: #f7f7f7;
+ border-radius: 2px;
+ font-family: monospace;
+ font-size: 12px;
+ overflow-x: auto;
+ white-space: pre;
+ line-height: 1.3;
+ user-select: text;
+ cursor: text;
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+}
+
+.tool-result {
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.usage-info {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px dashed #e0e0e0;
+ font-size: 12px;
+ color: #666;
+}
+
+/* Message type styles */
+.user .message-icon {
+ background-color: #2196f3;
+}
+
+.agent .message-icon {
+ background-color: #4caf50;
+}
+
+.tool .message-icon {
+ background-color: #ff9800;
+}
+
+.error .message-icon {
+ background-color: #f44336;
+}
+
+.end-of-turn {
+ margin-bottom: 15px;
+}
+
+.end-of-turn::after {
+ content: "End of Turn";
+ position: absolute;
+ left: 15px;
+ bottom: -10px;
+ transform: translateX(-50%);
+ font-size: 10px;
+ color: #666;
+ background: #f0f0f0;
+ padding: 1px 4px;
+ border-radius: 3px;
+}
+
+.collapsible {
+ cursor: pointer;
+ background-color: #f0f0f0;
+ padding: 5px 10px;
+ border: none;
+ border-radius: 4px;
+ text-align: left;
+ font-size: 12px;
+ margin-top: 5px;
+}
+
+.collapsed {
+ max-height: 50px;
+ overflow-y: hidden;
+ position: relative;
+ text-overflow: ellipsis;
+}
+
+/* Removed the gradient effect */
+
+.loader {
+ display: flex;
+ justify-content: center;
+ padding: 20px;
+}
+
+.loader::after {
+ content: "";
+ width: 30px;
+ height: 30px;
+ border: 3px solid #f3f3f3;
+ border-top: 3px solid #3498db;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+/* Chat styles */
+.chat-container {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ background: #f0f0f0;
+ padding: 15px;
+ box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
+ z-index: 1000;
+}
+
+.chat-input-wrapper {
+ display: flex;
+ max-width: 1200px;
+ margin: 0 auto;
+ gap: 10px;
+}
+
+#chatInput {
+ flex: 1;
+ padding: 12px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ resize: none;
+ font-family: monospace;
+ font-size: 12px;
+ min-height: 40px;
+ max-height: 120px;
+ background: #f7f7f7;
+}
+
+#sendChatButton {
+ background-color: #2196f3;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 0 20px;
+ cursor: pointer;
+ font-weight: 600;
+}
+
+#sendChatButton:hover {
+ background-color: #0d8bf2;
+}
+
+/* Copy button styles */
+.message-text-container,
+.tool-result-container {
+ position: relative;
+}
+
+.message-actions {
+ position: absolute;
+ top: 5px;
+ right: 5px;
+ z-index: 10;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+
+.message-text-container:hover .message-actions,
+.tool-result-container:hover .message-actions {
+ opacity: 1;
+}
+
+.copy-button {
+ background-color: rgba(255, 255, 255, 0.9);
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ color: #555;
+ cursor: pointer;
+ font-size: 12px;
+ padding: 2px 8px;
+ transition: all 0.2s ease;
+}
+
+.copy-button:hover {
+ background-color: #f0f0f0;
+ color: #333;
+}
+
+/* Diff View Styles */
+.diff-view {
+ width: 100%;
+ background-color: #f5f5f5;
+ border-radius: 8px;
+ overflow: hidden;
+ margin-bottom: 20px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ display: flex;
+ flex-direction: column;
+}
+
+.diff-tabs {
+ display: flex;
+ background-color: #e0e0e0;
+ border-bottom: 1px solid #ccc;
+}
+
+.diff-tab-button {
+ padding: 8px 16px;
+ border: none;
+ background: none;
+ font-size: 14px;
+ cursor: pointer;
+ outline: none;
+ transition: background-color 0.2s;
+}
+
+.diff-tab-button:hover {
+ background-color: #d0d0d0;
+}
+
+.diff-tab-button.active {
+ background-color: #fff;
+ border-bottom: 2px solid #3498db;
+}
+
+.diff-container {
+ flex: 1;
+ overflow: hidden;
+}
+
+/* Removed diff-header for more space */
+
+.diff-content {
+ padding: 15px;
+ margin: 0;
+ max-height: 70vh;
+ overflow-y: auto;
+ font-family: Consolas, Monaco, "Andale Mono", monospace;
+ font-size: 14px;
+ line-height: 1.5;
+ white-space: pre;
+ tab-size: 4;
+ background-color: #fff;
+}
+
+.diff-content .diff-line {
+ padding: 0 5px;
+ white-space: pre;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.diff-content .diff-line:hover {
+ background-color: #e6f7ff;
+}
+
+.diff-content .diff-add {
+ background-color: #e6ffed;
+ color: #22863a;
+}
+
+.diff-content .diff-remove {
+ background-color: #ffeef0;
+ color: #cb2431;
+}
+
+.diff-content .diff-info {
+ color: #6a737d;
+ background-color: #f0f0f0;
+}
+
+.diff-comment-box {
+ position: fixed;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 80%;
+ max-width: 600px;
+ background-color: #fff;
+ padding: 20px;
+ border-radius: 8px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
+ z-index: 1000;
+}
+
+.diff-comment-box h3 {
+ margin-top: 0;
+ margin-bottom: 15px;
+ font-size: 18px;
+}
+
+.selected-line {
+ background-color: #f5f5f5;
+ padding: 10px;
+ margin-bottom: 15px;
+ border-radius: 4px;
+ border-left: 3px solid #0366d6;
+}
+
+.selected-line pre {
+ margin: 5px 0 0 0;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ font-family: Consolas, Monaco, "Andale Mono", monospace;
+ font-size: 14px;
+}
+
+#diffCommentInput {
+ width: 100%;
+ min-height: 100px;
+ padding: 10px;
+ margin-bottom: 15px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ resize: vertical;
+ font-family: Arial, sans-serif;
+}
+
+.diff-comment-buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.diff-comment-buttons button {
+ padding: 8px 15px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-weight: 500;
+}
+
+#submitDiffComment {
+ background-color: #0366d6;
+ color: white;
+}
+
+#submitDiffComment:hover {
+ background-color: #0256bd;
+}
+
+#cancelDiffComment {
+ background-color: #e1e4e8;
+ color: #24292e;
+}
+
+#cancelDiffComment:hover {
+ background-color: #d1d5da;
+}
+
+/* View Mode Button Styles */
+.view-mode-buttons {
+ display: flex;
+ gap: 8px;
+ margin-right: 10px;
+}
+
+.emoji-button {
+ font-size: 18px;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: white;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ padding: 0;
+ line-height: 1;
+}
+
+.emoji-button:hover {
+ background-color: #f0f0f0;
+ transform: translateY(-2px);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.emoji-button.active {
+ background-color: #e6f7ff;
+ border-color: #1890ff;
+ color: #1890ff;
+}
+
+#showConversationButton.active {
+ background-color: #e6f7ff;
+ border-color: #1890ff;
+}
+
+#showDiffButton.active {
+ background-color: #f6ffed;
+ border-color: #52c41a;
+}
+
+#showChartsButton.active {
+ background-color: #fff2e8;
+ border-color: #fa8c16;
+}
+
+.stop-button:hover {
+ background-color: #c82333 !important;
+}
+
+/* Chart View Styles */
+.chart-view {
+ width: 100%;
+ background-color: #ffffff;
+ border-radius: 8px;
+ overflow: hidden;
+ margin-bottom: 20px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ padding: 15px;
+}
+
+.chart-container {
+ width: 100%;
+ height: auto;
+ overflow: auto;
+}
+
+.chart-section {
+ margin-bottom: 30px;
+ border-bottom: 1px solid #eee;
+ padding-bottom: 20px;
+}
+
+/* Terminal View Styles */
+.terminal-view {
+ width: 100%;
+ background-color: #f5f5f5;
+ border-radius: 8px;
+ overflow: hidden;
+ margin-bottom: 20px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ padding: 15px;
+ height: 70vh;
+}
+
+.terminal-container {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+#showTerminalButton.active {
+ background-color: #fef0f0;
+ border-color: #ff4d4f;
+}
+
+.chart-section:last-child {
+ border-bottom: none;
+ margin-bottom: 0;
+}
+
+.chart-section h3 {
+ margin-top: 0;
+ margin-bottom: 15px;
+ font-size: 18px;
+ color: #333;
+}
+
+#costChart,
+#messagesChart {
+ width: 100%;
+ min-height: 300px;
+ margin-bottom: 10px;
+}
+
+/* Tool calls container styles */
+.tool-calls-container {
+ /* Removed dotted border */
+}
+
+.tool-calls-toggle {
+ cursor: pointer;
+ background-color: #f0f0f0;
+ padding: 5px 10px;
+ border: none;
+ border-radius: 4px;
+ text-align: left;
+ font-size: 12px;
+ margin-top: 5px;
+ color: #555;
+ font-weight: 500;
+}
+
+.tool-calls-toggle:hover {
+ background-color: #e0e0e0;
+}
+
+.tool-calls-details {
+ margin-top: 10px;
+ transition: max-height 0.3s ease;
+}
+
+.tool-calls-details.collapsed {
+ max-height: 0;
+ overflow: hidden;
+ margin-top: 0;
+}
+
+.tool-call {
+ background: #f9f9f9;
+ border-radius: 4px;
+ padding: 10px;
+ margin-bottom: 10px;
+ border-left: 3px solid #4caf50;
+}
+
+.tool-call-header {
+ margin-bottom: 8px;
+ font-size: 14px;
+ padding: 2px 0;
+}
+
+/* Compact tool display styles */
+.tool-compact-line {
+ font-family: monospace;
+ font-size: 12px;
+ line-height: 1.4;
+ padding: 4px 6px;
+ background: #f8f8f8;
+ border-radius: 3px;
+ position: relative;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+ display: flex;
+ align-items: center;
+}
+
+.tool-result-inline {
+ font-family: monospace;
+ color: #0066bb;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 400px;
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.copy-inline-button {
+ font-size: 10px;
+ padding: 2px 4px;
+ margin-left: 8px;
+ background: #eee;
+ border: none;
+ border-radius: 3px;
+ cursor: pointer;
+ opacity: 0.7;
+}
+
+.copy-inline-button:hover {
+ opacity: 1;
+ background: #ddd;
+}
+
+.tool-input.compact,
+.tool-result.compact {
+ margin: 2px 0;
+ padding: 4px;
+ font-size: 12px;
+}
+
+/* Removed old compact container CSS */
+
+/* Ultra-compact tool call box styles */
+.tool-calls-header {
+ /* Empty header - just small spacing */
+}
+
+.tool-call-boxes-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.tool-call-wrapper {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 4px;
+}
+
+.tool-call-box {
+ display: inline-flex;
+ align-items: center;
+ background: #f0f0f0;
+ border-radius: 4px;
+ padding: 3px 8px;
+ font-size: 12px;
+ cursor: pointer;
+ max-width: 320px;
+ position: relative;
+ border: 1px solid #ddd;
+ transition: background-color 0.2s;
+}
+
+.tool-call-box:hover {
+ background-color: #e8e8e8;
+}
+
+.tool-call-box.expanded {
+ background-color: #e0e0e0;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ border-bottom: 1px solid #ccc;
+}
+
+.tool-call-name {
+ font-weight: bold;
+ margin-right: 6px;
+ color: #444;
+}
+
+.tool-call-input {
+ color: #666;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-family: monospace;
+ font-size: 11px;
+}
+
+/* Removed old expanded view CSS */
+
+/* Custom styles for IRC-like experience */
+.user .message-content {
+ border-left-color: #2196f3;
+}
+
+.agent .message-content {
+ border-left-color: #4caf50;
+}
+
+.tool .message-content {
+ border-left-color: #ff9800;
+}
+
+.error .message-content {
+ border-left-color: #f44336;
+}
+
+/* Make message type display bold but without the IRC-style markers */
+.message-type {
+ font-weight: bold;
+}
+
+/* Tool call cards */
+.tool-call-cards-container {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+/* Commit message styling */
+.message.commit {
+ background-color: #f0f7ff;
+ border-left: 4px solid #0366d6;
+}
+
+.commits-container {
+ margin-top: 10px;
+ padding: 5px;
+}
+
+.commits-header {
+ font-weight: bold;
+ margin-bottom: 5px;
+ color: #24292e;
+}
+
+.commit-boxes-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.tool-call-card {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ background-color: #f9f9f9;
+ overflow: hidden;
+ cursor: pointer;
+}
+
+/* Compact view (default) */
+.tool-call-compact-view {
+ display: flex;
+ align-items: center;
+ padding: 0px 6px;
+ gap: 8px;
+ background-color: #f9f9f9;
+ font-size: 0.9em;
+ white-space: nowrap;
+ overflow: visible; /* Don't hide overflow, we'll handle text truncation per element */
+ position: relative; /* For positioning the expand icon */
+}
+
+/* Expanded view (hidden by default) */
+.tool-call-card.collapsed .tool-call-expanded-view {
+ display: none;
+}
+
+.tool-call-expanded-view {
+ display: flex;
+ flex-direction: column;
+ border-top: 1px solid #eee;
+}
+
+.tool-call-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 10px;
+ background-color: #f0f0f0;
+ border-bottom: 1px solid #ddd;
+ font-weight: bold;
+}
+
+.tool-call-name {
+ font-family: var(--monospace-font);
+ color: #0066cc;
+ font-weight: bold;
+}
+
+.tool-call-status {
+ margin-right: 4px;
+ min-width: 1em;
+ text-align: center;
+}
+
+.tool-call-status.spinner {
+ animation: spin 1s infinite linear;
+ display: inline-block;
+ width: 1em;
+}
+
+.tool-call-time {
+ margin-left: 8px;
+ font-size: 0.85em;
+ color: #666;
+ font-weight: normal;
+}
+
+.tool-call-input-preview {
+ color: #555;
+ font-family: var(--monospace-font);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 30%;
+ background-color: rgba(240, 240, 240, 0.5);
+ padding: 2px 5px;
+ border-radius: 3px;
+ font-size: 0.9em;
+}
+
+.tool-call-result-preview {
+ color: #28a745;
+ font-family: var(--monospace-font);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 40%;
+ background-color: rgba(240, 248, 240, 0.5);
+ padding: 2px 5px;
+ border-radius: 3px;
+ font-size: 0.9em;
+}
+
+.tool-call-expand-icon {
+ position: absolute;
+ right: 10px;
+ font-size: 0.8em;
+ color: #888;
+}
+
+.tool-call-input {
+ padding: 6px 10px;
+ border-bottom: 1px solid #eee;
+ font-family: var(--monospace-font);
+ font-size: 0.9em;
+ white-space: pre-wrap;
+ word-break: break-all;
+ background-color: #f5f5f5;
+}
+
+.tool-call-result {
+ padding: 6px 10px;
+ font-family: var(--monospace-font);
+ font-size: 0.9em;
+ white-space: pre-wrap;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.tool-call-result pre {
+ margin: 0;
+ white-space: pre-wrap;
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+/* Standalone tool messages (legacy/disconnected) */
+.tool-details.standalone .tool-header {
+ border-radius: 4px;
+ background-color: #fff3cd;
+ border-color: #ffeeba;
+}
+
+.tool-details.standalone .tool-warning {
+ margin-left: 10px;
+ font-size: 0.85em;
+ color: #856404;
+ font-style: italic;
+}
+
+/* Tool call expanded view with sections */
+.tool-call-section {
+ border-bottom: 1px solid #eee;
+}
+
+.tool-call-section:last-child {
+ border-bottom: none;
+}
+
+.tool-call-section-label {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 10px;
+ background-color: #f5f5f5;
+ font-weight: bold;
+ font-size: 0.9em;
+}
+
+.tool-call-section-content {
+ padding: 0;
+}
+
+.tool-call-copy-btn {
+ background-color: #f0f0f0;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ padding: 2px 8px;
+ font-size: 0.8em;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.tool-call-copy-btn:hover {
+ background-color: #e0e0e0;
+}
+
+/* Override for tool call input in expanded view */
+.tool-call-section-content .tool-call-input {
+ margin: 0;
+ padding: 8px 10px;
+ border: none;
+ background-color: #fff;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.title-container {
+ display: flex;
+ flex-direction: column;
+ max-width: 33%;
+ overflow: hidden;
+}
+
+.commit-box {
+ border: 1px solid #d1d5da;
+ border-radius: 4px;
+ overflow: hidden;
+ background-color: #ffffff;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ max-width: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.commit-preview {
+ padding: 8px 12px;
+ cursor: pointer;
+ font-family: monospace;
+ background-color: #f6f8fa;
+ border-bottom: 1px dashed #d1d5da;
+}
+
+.commit-preview:hover {
+ background-color: #eef2f6;
+}
+
+.commit-hash {
+ color: #0366d6;
+ font-weight: bold;
+}
+
+.commit-details {
+ padding: 8px 12px;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.commit-details pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.commit-details.is-hidden {
+ display: none;
+}
+
+.pushed-branch {
+ color: #28a745;
+ font-weight: 500;
+ margin-left: 6px;
+}
+
+.commit-diff-button {
+ padding: 6px 12px;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ background-color: #f7f7f7;
+ color: #24292e;
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ margin: 8px 12px;
+ display: block;
+}
+
+.commit-diff-button:hover {
+ background-color: #e7e7e7;
+ border-color: #aaa;
+}
+
+/* Hide views initially to prevent flash of content */
+.timeline-container .timeline,
+.timeline-container .diff-view,
+.timeline-container .chart-view,
+.timeline-container .terminal-view {
+ visibility: hidden;
+}
+
+/* Will be set by JavaScript once we know which view to display */
+.timeline-container.view-initialized .timeline,
+.timeline-container.view-initialized .diff-view,
+.timeline-container.view-initialized .chart-view,
+.timeline-container.view-initialized .terminal-view {
+ visibility: visible;
+}
+
+.markdown-content {
+ box-sizing: border-box;
+ min-width: 200px;
+ margin: 0 auto;
+}
+
+.markdown-content p {
+ margin-block-start: 0.5em;
+ margin-block-end: 0.5em
+}
\ No newline at end of file
diff --git a/loop/webui/src/timeline.html b/loop/webui/src/timeline.html
new file mode 100644
index 0000000..46144c1
--- /dev/null
+++ b/loop/webui/src/timeline.html
@@ -0,0 +1,158 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>sketch coding assistant</title>
+ <!-- Import the diff2html CSS -->
+ <link rel="stylesheet" href="static/diff2html.min.css" />
+ <link rel="stylesheet" href="static/timeline.css" />
+ <link rel="stylesheet" href="static/diff2.css" />
+ <link rel="stylesheet" href="static/xterm.css" />
+ <link rel="stylesheet" href="static/tailwind.css" />
+ </head>
+ <body>
+ <div class="top-banner">
+ <div class="title-container">
+ <h1 class="banner-title">sketch coding assistant</h1>
+ <h2 id="chatTitle" class="chat-title"></h2>
+ </div>
+ <div class="info-grid">
+ <div class="info-item">
+ <a href="logs" class="text-blue-600 font-medium hover:text-blue-800 hover:underline">Logs</a>
+ </div>
+ <div class="info-item">
+ <a href="download" class="text-blue-600 font-medium hover:text-blue-800 hover:underline">Download</a>
+ </div>
+ <div class="info-item">
+ <span id="hostname" class="info-value">Loading...</span>
+ </div>
+ <div class="info-item">
+ <span id="workingDir" class="info-value">Loading...</span>
+ </div>
+ <div class="info-item">
+ <span class="info-label">Commit:</span>
+ <span id="initialCommit" class="info-value">Loading...</span>
+ </div>
+ <div class="info-item">
+ <span class="info-label">Msgs:</span>
+ <span id="messageCount" class="info-value">0</span>
+ </div>
+ <div class="info-item">
+ <span class="info-label">In:</span>
+ <span id="inputTokens" class="info-value">0</span>
+ </div>
+ <div class="info-item">
+ <span class="info-label">Cache Read:</span>
+ <span id="cacheReadInputTokens" class="info-value">0</span>
+ </div>
+ <div class="info-item">
+ <span class="info-label">Cache Create:</span>
+ <span id="cacheCreationInputTokens" class="info-value">0</span>
+ </div>
+ <div class="info-item">
+ <span class="info-label">Out:</span>
+ <span id="outputTokens" class="info-value">0</span>
+ </div>
+ <div class="info-item">
+ <span class="info-label">Cost:</span>
+ <span id="totalCost" class="info-value cost">$0.00</span>
+ </div>
+ </div>
+ <div class="refresh-control">
+ <div class="view-mode-buttons">
+ <button
+ id="showConversationButton"
+ class="emoji-button"
+ title="Conversation View"
+ >
+ 💬
+ </button>
+ <button
+ id="showDiff2Button"
+ class="emoji-button"
+ title="Diff View"
+ >
+ ±
+ </button>
+ <button
+ id="showChartsButton"
+ class="emoji-button"
+ title="Charts View"
+ >
+ 📈
+ </button>
+ <button
+ id="showTerminalButton"
+ class="emoji-button"
+ title="Terminal View"
+ >
+ 💻
+ </button>
+ </div>
+ <button id="stopButton" class="refresh-button stop-button">Stop</button>
+ <div class="poll-updates">
+ <input type="checkbox" id="pollToggle" checked />
+ <label for="pollToggle">Poll</label>
+ </div>
+ <div class="status-container">
+ <span id="pollingIndicator" class="polling-indicator"></span>
+ <span id="statusText" class="status-text"></span>
+ </div>
+ </div>
+ </div>
+
+ <div class="timeline-container">
+ <div id="timeline" class="timeline empty"></div>
+ <div id="diff2View" class="diff-view" style="display: none">
+ <div id="diff2Container" class="diff-container">
+ <div id="diff-view-controls">
+ <div class="diff-view-format">
+ <label>
+ <input type="radio" name="diffViewFormat" value="side-by-side" checked> Side-by-side
+ </label>
+ <label>
+ <input type="radio" name="diffViewFormat" value="line-by-line"> Line-by-line
+ </label>
+ </div>
+ </div>
+ <div id="diff2htmlContent" class="diff2html-content"></div>
+ </div>
+ </div>
+ <div id="chartView" class="chart-view" style="display: none">
+ <div id="chartContainer" class="chart-container"></div>
+ </div>
+ <div id="terminalView" class="terminal-view" style="display: none">
+ <div id="terminalContainer" class="terminal-container"></div>
+ </div>
+ <div id="diffCommentBox" class="diff-comment-box" style="display: none">
+ <h3>Add a comment</h3>
+ <div class="selected-line">
+ Line:
+ <pre id="selectedLine"></pre>
+ </div>
+ <textarea
+ id="diffCommentInput"
+ placeholder="Enter your comment about this line..."
+ ></textarea>
+ <div class="diff-comment-buttons">
+ <button id="submitDiffComment">Add Comment</button>
+ <button id="cancelDiffComment">Cancel</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="chat-container">
+ <div class="chat-input-wrapper">
+ <textarea
+ id="chatInput"
+ placeholder="Type your message here and press Enter to send..."
+ autofocus
+ ></textarea>
+ <button id="sendChatButton">Send</button>
+ </div>
+ </div>
+
+ <script src="static/timeline.js"></script>
+ </body>
+</html>
diff --git a/loop/webui/src/timeline.ts b/loop/webui/src/timeline.ts
new file mode 100644
index 0000000..eef2726
--- /dev/null
+++ b/loop/webui/src/timeline.ts
@@ -0,0 +1,641 @@
+import { TimelineMessage } from "./timeline/types";
+import { formatNumber } from "./timeline/utils";
+import { checkShouldScroll } from "./timeline/scroll";
+import { ChartManager } from "./timeline/charts";
+import { ConnectionStatus, DataManager } from "./timeline/data";
+import { DiffViewer } from "./timeline/diffviewer";
+import { MessageRenderer } from "./timeline/renderer";
+import { TerminalHandler } from "./timeline/terminal";
+
+/**
+ * TimelineManager - Class to manage the timeline UI and functionality
+ */
+class TimelineManager {
+ private diffViewer = new DiffViewer();
+ private terminalHandler = new TerminalHandler();
+ private chartManager = new ChartManager();
+ private messageRenderer = new MessageRenderer();
+ private dataManager = new DataManager();
+
+ private viewMode: "chat" | "diff2" | "charts" | "terminal" = "chat";
+ shouldScrollToBottom: boolean;
+
+ constructor() {
+ // Initialize when DOM is ready
+ document.addEventListener("DOMContentLoaded", () => {
+ // First initialize from URL params to prevent flash of incorrect view
+ // This must happen before setting up other event handlers
+ void this.initializeViewFromUrl()
+ .then(() => {
+ // Continue with the rest of initialization
+ return this.initialize();
+ })
+ .catch((err) => {
+ console.error("Failed to initialize timeline:", err);
+ });
+ });
+
+ // Add popstate event listener to handle browser back/forward navigation
+ window.addEventListener("popstate", (event) => {
+ if (event.state && event.state.mode) {
+ // Using void to handle the promise returned by toggleViewMode
+ void this.toggleViewMode(event.state.mode);
+ } else {
+ // If no state or no mode in state, default to chat view
+ void this.toggleViewMode("chat");
+ }
+ });
+
+ // Listen for commit diff event from MessageRenderer
+ document.addEventListener("showCommitDiff", ((e: CustomEvent) => {
+ const { commitHash } = e.detail;
+ this.diffViewer.showCommitDiff(
+ commitHash,
+ (mode: "chat" | "diff2" | "terminal" | "charts") =>
+ this.toggleViewMode(mode)
+ );
+ }) as EventListener);
+ }
+
+ /**
+ * Initialize the timeline manager
+ */
+ private async initialize(): Promise<void> {
+ // Set up data manager event listeners
+ this.dataManager.addEventListener(
+ "dataChanged",
+ this.handleDataChanged.bind(this)
+ );
+ this.dataManager.addEventListener(
+ "connectionStatusChanged",
+ this.handleConnectionStatusChanged.bind(this)
+ );
+
+ // Initialize the data manager
+ await this.dataManager.initialize();
+
+ // URL parameters have already been read in constructor
+ // to prevent flash of incorrect content
+
+ // Set up conversation button handler
+ document
+ .getElementById("showConversationButton")
+ ?.addEventListener("click", async () => {
+ this.toggleViewMode("chat");
+ });
+
+ // Set up diff2 button handler
+ document
+ .getElementById("showDiff2Button")
+ ?.addEventListener("click", async () => {
+ this.toggleViewMode("diff2");
+ });
+
+ // Set up charts button handler
+ document
+ .getElementById("showChartsButton")
+ ?.addEventListener("click", async () => {
+ this.toggleViewMode("charts");
+ });
+
+ // Set up terminal button handler
+ document
+ .getElementById("showTerminalButton")
+ ?.addEventListener("click", async () => {
+ this.toggleViewMode("terminal");
+ });
+
+ // The active button will be set by toggleViewMode
+ // We'll initialize view based on URL params or default to chat view if no params
+ // We defer button activation to the toggleViewMode function
+
+ // Set up stop button handler
+ document
+ .getElementById("stopButton")
+ ?.addEventListener("click", async () => {
+ this.stopInnerLoop();
+ });
+
+ const pollToggleCheckbox = document.getElementById(
+ "pollToggle"
+ ) as HTMLInputElement;
+ pollToggleCheckbox?.addEventListener("change", () => {
+ this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
+ const statusText = document.getElementById("statusText");
+ if (statusText) {
+ if (pollToggleCheckbox.checked) {
+ statusText.textContent = "Polling for updates...";
+ } else {
+ statusText.textContent = "Polling stopped";
+ }
+ }
+ });
+
+ // Initial data fetch and polling is now handled by the DataManager
+
+ // Set up chat functionality
+ this.setupChatBox();
+
+ // Set up keyboard shortcuts
+ this.setupKeyboardShortcuts();
+
+ // Set up spacing adjustments
+ this.adjustChatSpacing();
+ window.addEventListener("resize", () => this.adjustChatSpacing());
+ }
+
+ /**
+ * Set up chat box event listeners
+ */
+ private setupChatBox(): void {
+ const chatInput = document.getElementById(
+ "chatInput"
+ ) as HTMLTextAreaElement;
+ const sendButton = document.getElementById("sendChatButton");
+
+ // Handle pressing Enter in the text area
+ chatInput?.addEventListener("keydown", (event: KeyboardEvent) => {
+ // Send message if Enter is pressed without Shift key
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault(); // Prevent default newline
+ this.sendChatMessage();
+ }
+ });
+
+ // Handle send button click
+ sendButton?.addEventListener("click", () => this.sendChatMessage());
+
+ // Set up mutation observer for the chat container
+ if (chatInput) {
+ chatInput.addEventListener("input", () => {
+ // When content changes, adjust the spacing
+ requestAnimationFrame(() => this.adjustChatSpacing());
+ });
+ }
+ }
+
+ /**
+ * Send the chat message to the server
+ */
+ private async sendChatMessage(): Promise<void> {
+ const chatInput = document.getElementById(
+ "chatInput"
+ ) as HTMLTextAreaElement;
+ if (!chatInput) return;
+
+ const message = chatInput.value.trim();
+
+ // Don't send empty messages
+ if (!message) return;
+
+ try {
+ // Send the message to the server
+ const response = await fetch("chat", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ message }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.text();
+ throw new Error(`Server error: ${response.status} - ${errorData}`);
+ }
+
+ // Clear the input after sending
+ chatInput.value = "";
+
+ // Reset data manager state to force a full refresh after sending a message
+ // This ensures we get all messages in the correct order
+ // Use private API for now - TODO: add a resetState() method to DataManager
+ (this.dataManager as any).nextFetchIndex = 0;
+ (this.dataManager as any).currentFetchStartIndex = 0;
+
+ // If in diff view, switch to conversation view
+ if (this.viewMode === "diff2") {
+ await this.toggleViewMode("chat");
+ }
+
+ // Refresh the timeline data to show the new message
+ await this.dataManager.fetchData();
+ } catch (error) {
+ console.error("Error sending chat message:", error);
+ const statusText = document.getElementById("statusText");
+ if (statusText) {
+ statusText.textContent = "Error sending message";
+ }
+ }
+ }
+
+ /**
+ * Handle data changed event from the data manager
+ */
+ private handleDataChanged(eventData: {
+ state: any;
+ newMessages: TimelineMessage[];
+ isFirstFetch?: boolean;
+ }): void {
+ const { state, newMessages, isFirstFetch } = eventData;
+
+ // Check if we should scroll to bottom BEFORE handling new data
+ this.shouldScrollToBottom = this.checkShouldScroll();
+
+ // Update state info in the UI
+ this.updateUIWithState(state);
+
+ // Update the timeline if there are new messages
+ if (newMessages.length > 0) {
+ // Initialize the message renderer with current state
+ this.messageRenderer.initialize(
+ this.dataManager.getIsFirstLoad(),
+ this.dataManager.getCurrentFetchStartIndex()
+ );
+
+ this.messageRenderer.renderTimeline(newMessages, isFirstFetch || false);
+
+ // Update chart data using our full messages array
+ this.chartManager.setChartData(
+ this.chartManager.calculateCumulativeCostData(
+ this.dataManager.getMessages()
+ )
+ );
+
+ // If in charts view, update the charts
+ if (this.viewMode === "charts") {
+ this.chartManager.renderCharts();
+ }
+
+ const statusTextEl = document.getElementById("statusText");
+ if (statusTextEl) {
+ statusTextEl.textContent = "Updated just now";
+ }
+ } else {
+ const statusTextEl = document.getElementById("statusText");
+ if (statusTextEl) {
+ statusTextEl.textContent = "No new messages";
+ }
+ }
+ }
+
+ /**
+ * Handle connection status changed event from the data manager
+ */
+ private handleConnectionStatusChanged(
+ status: ConnectionStatus,
+ errorMessage?: string
+ ): void {
+ const pollingIndicator = document.getElementById("pollingIndicator");
+ if (!pollingIndicator) return;
+
+ // Remove all status classes
+ pollingIndicator.classList.remove("active", "error");
+
+ // Add appropriate class based on status
+ if (status === "connected") {
+ pollingIndicator.classList.add("active");
+ } else if (status === "disconnected") {
+ pollingIndicator.classList.add("error");
+ }
+
+ // Update status text if error message is provided
+ if (errorMessage) {
+ const statusTextEl = document.getElementById("statusText");
+ if (statusTextEl) {
+ statusTextEl.textContent = errorMessage;
+ }
+ }
+ }
+
+ /**
+ * Update UI elements with state data
+ */
+ private updateUIWithState(state: any): void {
+ // Update state info in the UI with safe getters
+ const hostnameEl = document.getElementById("hostname");
+ if (hostnameEl) {
+ hostnameEl.textContent = state?.hostname ?? "Unknown";
+ }
+
+ const workingDirEl = document.getElementById("workingDir");
+ if (workingDirEl) {
+ workingDirEl.textContent = state?.working_dir ?? "Unknown";
+ }
+
+ const initialCommitEl = document.getElementById("initialCommit");
+ if (initialCommitEl) {
+ initialCommitEl.textContent = state?.initial_commit
+ ? state.initial_commit.substring(0, 8)
+ : "Unknown";
+ }
+
+ const messageCountEl = document.getElementById("messageCount");
+ if (messageCountEl) {
+ messageCountEl.textContent = state?.message_count ?? "0";
+ }
+
+ const chatTitleEl = document.getElementById("chatTitle");
+ const bannerTitleEl = document.querySelector(".banner-title");
+
+ if (chatTitleEl && bannerTitleEl) {
+ if (state?.title) {
+ chatTitleEl.textContent = state.title;
+ chatTitleEl.style.display = "block";
+ bannerTitleEl.textContent = "sketch"; // Shorten title when chat title exists
+ } else {
+ chatTitleEl.style.display = "none";
+ bannerTitleEl.textContent = "sketch coding assistant"; // Full title when no chat title
+ }
+ }
+
+ // Get token and cost info safely
+ const inputTokens = state?.total_usage?.input_tokens ?? 0;
+ const outputTokens = state?.total_usage?.output_tokens ?? 0;
+ const cacheReadInputTokens =
+ state?.total_usage?.cache_read_input_tokens ?? 0;
+ const cacheCreationInputTokens =
+ state?.total_usage?.cache_creation_input_tokens ?? 0;
+ const totalCost = state?.total_usage?.total_cost_usd ?? 0;
+
+ const inputTokensEl = document.getElementById("inputTokens");
+ if (inputTokensEl) {
+ inputTokensEl.textContent = formatNumber(inputTokens, "0");
+ }
+
+ const outputTokensEl = document.getElementById("outputTokens");
+ if (outputTokensEl) {
+ outputTokensEl.textContent = formatNumber(outputTokens, "0");
+ }
+
+ const cacheReadInputTokensEl = document.getElementById(
+ "cacheReadInputTokens"
+ );
+ if (cacheReadInputTokensEl) {
+ cacheReadInputTokensEl.textContent = formatNumber(
+ cacheReadInputTokens,
+ "0"
+ );
+ }
+
+ const cacheCreationInputTokensEl = document.getElementById(
+ "cacheCreationInputTokens"
+ );
+ if (cacheCreationInputTokensEl) {
+ cacheCreationInputTokensEl.textContent = formatNumber(
+ cacheCreationInputTokens,
+ "0"
+ );
+ }
+
+ const totalCostEl = document.getElementById("totalCost");
+ if (totalCostEl) {
+ totalCostEl.textContent = `$${totalCost.toFixed(2)}`;
+ }
+ }
+
+ /**
+ * Check if we should scroll to the bottom
+ */
+ private checkShouldScroll(): boolean {
+ return checkShouldScroll(this.dataManager.getIsFirstLoad());
+ }
+
+ /**
+ * Dynamically adjust body padding based on the chat container height and top banner
+ */
+ private adjustChatSpacing(): void {
+ const chatContainer = document.querySelector(".chat-container");
+ const topBanner = document.querySelector(".top-banner");
+
+ if (chatContainer) {
+ const chatHeight = (chatContainer as HTMLElement).offsetHeight;
+ document.body.style.paddingBottom = `${chatHeight + 20}px`; // 20px extra for spacing
+ }
+
+ if (topBanner) {
+ const topHeight = (topBanner as HTMLElement).offsetHeight;
+ document.body.style.paddingTop = `${topHeight + 20}px`; // 20px extra for spacing
+ }
+ }
+
+ /**
+ * Set up keyboard shortcuts
+ */
+ private setupKeyboardShortcuts(): void {
+ // Add keyboard shortcut to automatically copy selected text with Ctrl+C (or Command+C on Mac)
+ document.addEventListener("keydown", (e: KeyboardEvent) => {
+ // We only want to handle Ctrl+C or Command+C
+ if ((e.ctrlKey || e.metaKey) && e.key === "c") {
+ // If text is already selected, we don't need to do anything special
+ // as the browser's default behavior will handle copying
+ // But we could add additional behavior here if needed
+ }
+ });
+ }
+
+ /**
+ * Toggle between different view modes: chat, diff2, charts
+ */
+ public async toggleViewMode(
+ mode: "chat" | "diff2" | "charts" | "terminal"
+ ): Promise<void> {
+ // Set the new view mode
+ this.viewMode = mode;
+
+ // Update URL with the current view mode
+ this.updateUrlForViewMode(mode);
+
+ // Get DOM elements
+ const timeline = document.getElementById("timeline");
+ const diff2View = document.getElementById("diff2View");
+ const chartView = document.getElementById("chartView");
+ const container = document.querySelector(".timeline-container");
+ const terminalView = document.getElementById("terminalView");
+ const conversationButton = document.getElementById(
+ "showConversationButton"
+ );
+ const diff2Button = document.getElementById("showDiff2Button");
+ const chartsButton = document.getElementById("showChartsButton");
+ const terminalButton = document.getElementById("showTerminalButton");
+
+ if (
+ !timeline ||
+ !diff2View ||
+ !chartView ||
+ !container ||
+ !conversationButton ||
+ !diff2Button ||
+ !chartsButton ||
+ !terminalView ||
+ !terminalButton
+ ) {
+ console.error("Required DOM elements not found");
+ return;
+ }
+
+ // Hide all views first
+ timeline.style.display = "none";
+ diff2View.style.display = "none";
+ chartView.style.display = "none";
+ terminalView.style.display = "none";
+
+ // Reset all button states
+ conversationButton.classList.remove("active");
+ diff2Button.classList.remove("active");
+ chartsButton.classList.remove("active");
+ terminalButton.classList.remove("active");
+
+ // Remove diff2-active and diff-active classes from container
+ container.classList.remove("diff2-active");
+ container.classList.remove("diff-active");
+
+ // If switching to chat view, clear the current commit hash
+ if (mode === "chat") {
+ this.diffViewer.clearCurrentCommitHash();
+ }
+
+ // Add class to indicate views are initialized (prevents flash of content)
+ container.classList.add("view-initialized");
+
+ // Show the selected view based on mode
+ switch (mode) {
+ case "chat":
+ timeline.style.display = "block";
+ conversationButton.classList.add("active");
+ break;
+ case "diff2":
+ diff2View.style.display = "block";
+ diff2Button.classList.add("active");
+ this.diffViewer.setViewMode(mode); // Update view mode in diff viewer
+ await this.diffViewer.loadDiff2HtmlContent();
+ break;
+ case "charts":
+ chartView.style.display = "block";
+ chartsButton.classList.add("active");
+ await this.chartManager.renderCharts();
+ break;
+ case "terminal":
+ terminalView.style.display = "block";
+ terminalButton.classList.add("active");
+ this.terminalHandler.setViewMode(mode); // Update view mode in terminal handler
+ this.diffViewer.setViewMode(mode); // Update view mode in diff viewer
+ await this.initializeTerminal();
+ break;
+ }
+ }
+
+ /**
+ * Initialize the terminal view
+ */
+ private async initializeTerminal(): Promise<void> {
+ // Use the TerminalHandler to initialize the terminal
+ await this.terminalHandler.initializeTerminal();
+ }
+
+ /**
+ * Initialize the view based on URL parameters
+ * This allows bookmarking and sharing of specific views
+ */
+ private async initializeViewFromUrl(): Promise<void> {
+ // Parse the URL parameters
+ const urlParams = new URLSearchParams(window.location.search);
+ const viewParam = urlParams.get("view");
+ const commitParam = urlParams.get("commit");
+
+ // Default to chat view if no valid view parameter is provided
+ if (!viewParam) {
+ // Explicitly set chat view to ensure button state is correct
+ await this.toggleViewMode("chat");
+ return;
+ }
+
+ // Check if the view parameter is valid
+ if (
+ viewParam === "chat" ||
+ viewParam === "diff2" ||
+ viewParam === "charts" ||
+ viewParam === "terminal"
+ ) {
+ // If it's a diff view with a commit hash, set the commit hash
+ if (viewParam === "diff2" && commitParam) {
+ this.diffViewer.setCurrentCommitHash(commitParam);
+ }
+
+ // Set the view mode
+ await this.toggleViewMode(
+ viewParam as "chat" | "diff2" | "charts" | "terminal"
+ );
+ }
+ }
+
+ /**
+ * Update URL to reflect current view mode for bookmarking and sharing
+ * @param mode The current view mode
+ */
+ private updateUrlForViewMode(
+ mode: "chat" | "diff2" | "charts" | "terminal"
+ ): void {
+ // Get the current URL without search parameters
+ const url = new URL(window.location.href);
+
+ // Clear existing parameters
+ url.search = "";
+
+ // Only add view parameter if not in default chat view
+ if (mode !== "chat") {
+ url.searchParams.set("view", mode);
+
+ // If in diff view and there's a commit hash, include that too
+ if (mode === "diff2" && this.diffViewer.getCurrentCommitHash()) {
+ url.searchParams.set("commit", this.diffViewer.getCurrentCommitHash());
+ }
+ }
+
+ // Update the browser history without reloading the page
+ window.history.pushState({ mode }, "", url.toString());
+ }
+
+ /**
+ * Stop the inner loop by calling the /cancel endpoint
+ */
+ private async stopInnerLoop(): Promise<void> {
+ if (!confirm("Are you sure you want to stop the current operation?")) {
+ return;
+ }
+
+ try {
+ const statusText = document.getElementById("statusText");
+ if (statusText) {
+ statusText.textContent = "Cancelling...";
+ }
+
+ const response = await fetch("cancel", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ reason: "User requested cancellation via UI" }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.text();
+ throw new Error(`Server error: ${response.status} - ${errorData}`);
+ }
+
+ // Parse the response
+ const _result = await response.json();
+ if (statusText) {
+ statusText.textContent = "Operation cancelled";
+ }
+ } catch (error) {
+ console.error("Error cancelling operation:", error);
+ const statusText = document.getElementById("statusText");
+ if (statusText) {
+ statusText.textContent = "Error cancelling operation";
+ }
+ }
+ }
+}
+
+// Create and initialize the timeline manager when the page loads
+const _timelineManager = new TimelineManager();
diff --git a/loop/webui/src/timeline/charts.ts b/loop/webui/src/timeline/charts.ts
new file mode 100644
index 0000000..0ed56e8
--- /dev/null
+++ b/loop/webui/src/timeline/charts.ts
@@ -0,0 +1,468 @@
+import type { TimelineMessage } from "./types";
+import vegaEmbed from "vega-embed";
+import { TopLevelSpec } from "vega-lite";
+
+/**
+ * ChartManager handles all chart-related functionality for the timeline.
+ * This includes rendering charts, calculating data, and managing chart state.
+ */
+export class ChartManager {
+ private chartData: { timestamp: Date; cost: number }[] = [];
+
+ /**
+ * Create a new ChartManager instance
+ */
+ constructor() {
+ this.chartData = [];
+ }
+
+ /**
+ * Calculate cumulative cost data from messages
+ */
+ public calculateCumulativeCostData(
+ messages: TimelineMessage[],
+ ): { timestamp: Date; cost: number }[] {
+ if (!messages || messages.length === 0) {
+ return [];
+ }
+
+ let cumulativeCost = 0;
+ const data: { timestamp: Date; cost: number }[] = [];
+
+ for (const message of messages) {
+ if (message.timestamp && message.usage && message.usage.cost_usd) {
+ const timestamp = new Date(message.timestamp);
+ cumulativeCost += message.usage.cost_usd;
+
+ data.push({
+ timestamp,
+ cost: cumulativeCost,
+ });
+ }
+ }
+
+ return data;
+ }
+
+ /**
+ * Get the current chart data
+ */
+ public getChartData(): { timestamp: Date; cost: number }[] {
+ return this.chartData;
+ }
+
+ /**
+ * Set chart data
+ */
+ public setChartData(data: { timestamp: Date; cost: number }[]): void {
+ this.chartData = data;
+ }
+
+ /**
+ * Fetch all messages to generate chart data
+ */
+ public async fetchAllMessages(): Promise<void> {
+ try {
+ // Fetch all messages in a single request
+ const response = await fetch("messages");
+ if (!response.ok) {
+ throw new Error(`Failed to fetch messages: ${response.status}`);
+ }
+
+ const allMessages = await response.json();
+ if (Array.isArray(allMessages)) {
+ // Sort messages chronologically
+ allMessages.sort((a, b) => {
+ const dateA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
+ const dateB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
+ return dateA - dateB;
+ });
+
+ // Calculate cumulative cost data
+ this.chartData = this.calculateCumulativeCostData(allMessages);
+ }
+ } catch (error) {
+ console.error("Error fetching messages for chart:", error);
+ this.chartData = [];
+ }
+ }
+
+ /**
+ * Render all charts in the chart view
+ */
+ public async renderCharts(): Promise<void> {
+ const chartContainer = document.getElementById("chartContainer");
+ if (!chartContainer) return;
+
+ try {
+ // Show loading state
+ chartContainer.innerHTML = "<div class='loader'></div>";
+
+ // Fetch messages if necessary
+ if (this.chartData.length === 0) {
+ await this.fetchAllMessages();
+ }
+
+ // Clear the container for multiple charts
+ chartContainer.innerHTML = "";
+
+ // Create cost chart container
+ const costChartDiv = document.createElement("div");
+ costChartDiv.className = "chart-section";
+ costChartDiv.innerHTML =
+ "<h3>Dollar Usage Over Time</h3><div id='costChart'></div>";
+ chartContainer.appendChild(costChartDiv);
+
+ // Create messages chart container
+ const messagesChartDiv = document.createElement("div");
+ messagesChartDiv.className = "chart-section";
+ messagesChartDiv.innerHTML =
+ "<h3>Message Timeline</h3><div id='messagesChart'></div>";
+ chartContainer.appendChild(messagesChartDiv);
+
+ // Render both charts
+ await this.renderDollarUsageChart();
+ await this.renderMessagesChart();
+ } catch (error) {
+ console.error("Error rendering charts:", error);
+ chartContainer.innerHTML = `<p>Error rendering charts: ${error instanceof Error ? error.message : "Unknown error"}</p>`;
+ }
+ }
+
+ /**
+ * Render the dollar usage chart using Vega-Lite
+ */
+ private async renderDollarUsageChart(): Promise<void> {
+ const costChartContainer = document.getElementById("costChart");
+ if (!costChartContainer) return;
+
+ try {
+ // Display cost chart using Vega-Lite
+ if (this.chartData.length === 0) {
+ costChartContainer.innerHTML =
+ "<p>No cost data available to display.</p>";
+ return;
+ }
+
+ // Create a Vega-Lite spec for the line chart
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const costSpec: any = {
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
+ description: "Cumulative cost over time",
+ width: "container",
+ height: 300,
+ data: {
+ values: this.chartData.map((d) => ({
+ timestamp: d.timestamp.toISOString(),
+ cost: d.cost,
+ })),
+ },
+ mark: {
+ type: "line",
+ point: true,
+ },
+ encoding: {
+ x: {
+ field: "timestamp",
+ type: "temporal",
+ title: "Time",
+ axis: {
+ format: "%H:%M:%S",
+ title: "Time",
+ labelAngle: -45,
+ },
+ },
+ y: {
+ field: "cost",
+ type: "quantitative",
+ title: "Cumulative Cost (USD)",
+ axis: {
+ format: "$,.4f",
+ },
+ },
+ tooltip: [
+ {
+ field: "timestamp",
+ type: "temporal",
+ title: "Time",
+ format: "%Y-%m-%d %H:%M:%S",
+ },
+ {
+ field: "cost",
+ type: "quantitative",
+ title: "Cumulative Cost",
+ format: "$,.4f",
+ },
+ ],
+ },
+ };
+
+ // Render the cost chart
+ await vegaEmbed(costChartContainer, costSpec, {
+ actions: true,
+ renderer: "svg",
+ });
+ } catch (error) {
+ console.error("Error rendering dollar usage chart:", error);
+ costChartContainer.innerHTML = `<p>Error rendering dollar usage chart: ${error instanceof Error ? error.message : "Unknown error"}</p>`;
+ }
+ }
+
+ /**
+ * Render the messages timeline chart using Vega-Lite
+ */
+ private async renderMessagesChart(): Promise<void> {
+ const messagesChartContainer = document.getElementById("messagesChart");
+ if (!messagesChartContainer) return;
+
+ try {
+ // Get all messages
+ const response = await fetch("messages");
+ if (!response.ok) {
+ throw new Error(`Failed to fetch messages: ${response.status}`);
+ }
+
+ const allMessages = await response.json();
+ if (!Array.isArray(allMessages) || allMessages.length === 0) {
+ messagesChartContainer.innerHTML =
+ "<p>No messages available to display.</p>";
+ return;
+ }
+
+ // Sort messages chronologically
+ allMessages.sort((a, b) => {
+ const dateA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
+ const dateB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
+ return dateA - dateB;
+ });
+
+ // Create unique indexes for all messages
+ const messageIndexMap = new Map<string, number>();
+ allMessages.forEach((msg, index) => {
+ // Create a unique ID for each message to track its position
+ const msgId = msg.timestamp ? msg.timestamp.toString() : `msg-${index}`;
+ messageIndexMap.set(msgId, index);
+ });
+
+ // Prepare data for messages with start_time and end_time (bar marks)
+ const barData = allMessages
+ .filter((msg) => msg.start_time && msg.end_time) // Only include messages with explicit start and end times
+ .map((msg) => {
+ // Parse start and end times
+ const startTime = new Date(msg.start_time!);
+ const endTime = new Date(msg.end_time!);
+
+ // Get the index for this message
+ const msgId = msg.timestamp ? msg.timestamp.toString() : "";
+ const index = messageIndexMap.get(msgId) || 0;
+
+ // Truncate content for tooltip readability
+ const displayContent = msg.content
+ ? msg.content.length > 100
+ ? msg.content.substring(0, 100) + "..."
+ : msg.content
+ : "No content";
+
+ // Prepare tool input and output for tooltip if applicable
+ const toolInput = msg.input
+ ? msg.input.length > 100
+ ? msg.input.substring(0, 100) + "..."
+ : msg.input
+ : "";
+
+ const toolResult = msg.tool_result
+ ? msg.tool_result.length > 100
+ ? msg.tool_result.substring(0, 100) + "..."
+ : msg.tool_result
+ : "";
+
+ return {
+ index: index,
+ message_type: msg.type,
+ content: displayContent,
+ tool_name: msg.tool_name || "",
+ tool_input: toolInput,
+ tool_result: toolResult,
+ start_time: startTime.toISOString(),
+ end_time: endTime.toISOString(),
+ message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
+ };
+ });
+
+ // Prepare data for messages with timestamps only (point marks)
+ const pointData = allMessages
+ .filter((msg) => msg.timestamp && !(msg.start_time && msg.end_time)) // Only messages with timestamp but without start/end times
+ .map((msg) => {
+ // Get the timestamp
+ const timestamp = new Date(msg.timestamp!);
+
+ // Get the index for this message
+ const msgId = msg.timestamp ? msg.timestamp.toString() : "";
+ const index = messageIndexMap.get(msgId) || 0;
+
+ // Truncate content for tooltip readability
+ const displayContent = msg.content
+ ? msg.content.length > 100
+ ? msg.content.substring(0, 100) + "..."
+ : msg.content
+ : "No content";
+
+ // Prepare tool input and output for tooltip if applicable
+ const toolInput = msg.input
+ ? msg.input.length > 100
+ ? msg.input.substring(0, 100) + "..."
+ : msg.input
+ : "";
+
+ const toolResult = msg.tool_result
+ ? msg.tool_result.length > 100
+ ? msg.tool_result.substring(0, 100) + "..."
+ : msg.tool_result
+ : "";
+
+ return {
+ index: index,
+ message_type: msg.type,
+ content: displayContent,
+ tool_name: msg.tool_name || "",
+ tool_input: toolInput,
+ tool_result: toolResult,
+ time: timestamp.toISOString(),
+ message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
+ };
+ });
+
+ // Check if we have any data to display
+ if (barData.length === 0 && pointData.length === 0) {
+ messagesChartContainer.innerHTML =
+ "<p>No message timing data available to display.</p>";
+ return;
+ }
+
+ // Calculate height based on number of unique messages
+ const chartHeight = 20 * Math.min(allMessages.length, 25); // Max 25 visible at once
+
+ // Create a layered Vega-Lite spec combining bars and points
+ const messagesSpec: TopLevelSpec = {
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
+ description: "Message Timeline",
+ width: "container",
+ height: chartHeight,
+ layer: [],
+ };
+
+ // Add bar layer if we have bar data
+ if (barData.length > 0) {
+ messagesSpec.layer.push({
+ data: { values: barData },
+ mark: {
+ type: "bar",
+ height: 16,
+ },
+ encoding: {
+ x: {
+ field: "start_time",
+ type: "temporal",
+ title: "Time",
+ axis: {
+ format: "%H:%M:%S",
+ title: "Time",
+ labelAngle: -45,
+ },
+ },
+ x2: { field: "end_time" },
+ y: {
+ field: "index",
+ type: "ordinal",
+ title: "Message Index",
+ axis: {
+ grid: true,
+ },
+ },
+ color: {
+ field: "message_type",
+ type: "nominal",
+ title: "Message Type",
+ legend: {},
+ },
+ tooltip: [
+ { field: "message_type", type: "nominal", title: "Type" },
+ { field: "tool_name", type: "nominal", title: "Tool" },
+ {
+ field: "start_time",
+ type: "temporal",
+ title: "Start Time",
+ format: "%H:%M:%S.%L",
+ },
+ {
+ field: "end_time",
+ type: "temporal",
+ title: "End Time",
+ format: "%H:%M:%S.%L",
+ },
+ { field: "content", type: "nominal", title: "Content" },
+ { field: "tool_input", type: "nominal", title: "Tool Input" },
+ { field: "tool_result", type: "nominal", title: "Tool Result" },
+ ],
+ },
+ });
+ }
+
+ // Add point layer if we have point data
+ if (pointData.length > 0) {
+ messagesSpec.layer.push({
+ data: { values: pointData },
+ mark: {
+ type: "point",
+ size: 100,
+ filled: true,
+ },
+ encoding: {
+ x: {
+ field: "time",
+ type: "temporal",
+ title: "Time",
+ axis: {
+ format: "%H:%M:%S",
+ title: "Time",
+ labelAngle: -45,
+ },
+ },
+ y: {
+ field: "index",
+ type: "ordinal",
+ title: "Message Index",
+ },
+ color: {
+ field: "message_type",
+ type: "nominal",
+ title: "Message Type",
+ },
+ tooltip: [
+ { field: "message_type", type: "nominal", title: "Type" },
+ { field: "tool_name", type: "nominal", title: "Tool" },
+ {
+ field: "time",
+ type: "temporal",
+ title: "Timestamp",
+ format: "%H:%M:%S.%L",
+ },
+ { field: "content", type: "nominal", title: "Content" },
+ { field: "tool_input", type: "nominal", title: "Tool Input" },
+ { field: "tool_result", type: "nominal", title: "Tool Result" },
+ ],
+ },
+ });
+ }
+
+ // Render the messages timeline chart
+ await vegaEmbed(messagesChartContainer, messagesSpec, {
+ actions: true,
+ renderer: "svg",
+ });
+ } catch (error) {
+ console.error("Error rendering messages chart:", error);
+ messagesChartContainer.innerHTML = `<p>Error rendering messages chart: ${error instanceof Error ? error.message : "Unknown error"}</p>`;
+ }
+ }
+}
diff --git a/loop/webui/src/timeline/commits.ts b/loop/webui/src/timeline/commits.ts
new file mode 100644
index 0000000..f4303f2
--- /dev/null
+++ b/loop/webui/src/timeline/commits.ts
@@ -0,0 +1,90 @@
+/**
+ * Utility functions for rendering commit messages in the timeline
+ */
+
+import { escapeHTML } from "./utils";
+
+interface Commit {
+ hash: string;
+ subject: string;
+ body: string;
+ pushed_branch?: string;
+}
+
+/**
+ * Create HTML elements to display commits in the timeline
+ * @param commits List of commit information to display
+ * @param diffViewerCallback Callback function to show commit diff when requested
+ * @returns The created HTML container element with commit information
+ */
+export function createCommitsContainer(
+ commits: Commit[],
+ diffViewerCallback: (commitHash: string) => void
+): HTMLElement {
+ const commitsContainer = document.createElement("div");
+ commitsContainer.className = "commits-container";
+
+ // Create a header for commits
+ const commitsHeaderRow = document.createElement("div");
+ commitsHeaderRow.className = "commits-header";
+ commitsHeaderRow.textContent = `${commits.length} new commit${commits.length > 1 ? "s" : ""} detected`;
+ commitsContainer.appendChild(commitsHeaderRow);
+
+ // Create a row for commit boxes
+ const commitBoxesRow = document.createElement("div");
+ commitBoxesRow.className = "commit-boxes-row";
+
+ // Add each commit as a box
+ commits.forEach((commit) => {
+ // Create the commit box
+ const commitBox = document.createElement("div");
+ commitBox.className = "commit-box";
+
+ // Show commit hash and subject line as the preview
+ const commitPreview = document.createElement("div");
+ commitPreview.className = "commit-preview";
+
+ // Include pushed branch information if available
+ let previewHTML = `<span class="commit-hash">${commit.hash.substring(0, 8)}</span> ${escapeHTML(commit.subject)}`;
+ if (commit.pushed_branch) {
+ previewHTML += ` <span class="pushed-branch">→ pushed to ${escapeHTML(commit.pushed_branch)}</span>`;
+ }
+
+ commitPreview.innerHTML = previewHTML;
+ commitBox.appendChild(commitPreview);
+
+ // Create expandable view for commit details
+ const expandedView = document.createElement("div");
+ expandedView.className = "commit-details is-hidden";
+ expandedView.innerHTML = `<pre>${escapeHTML(commit.body)}</pre>`;
+ commitBox.appendChild(expandedView);
+
+ // Toggle visibility of expanded view when clicking the preview
+ commitPreview.addEventListener("click", (event) => {
+ // If holding Ctrl/Cmd key, show diff for this commit
+ if (event.ctrlKey || event.metaKey) {
+ // Call the diff viewer callback with the commit hash
+ diffViewerCallback(commit.hash);
+ } else {
+ // Normal behavior - toggle expanded view
+ expandedView.classList.toggle("is-hidden");
+ }
+ });
+
+ // Add a diff button to view commit changes
+ const diffButton = document.createElement("button");
+ diffButton.className = "commit-diff-button";
+ diffButton.textContent = "View Changes";
+ diffButton.addEventListener("click", (event) => {
+ event.stopPropagation(); // Prevent triggering the parent click event
+ diffViewerCallback(commit.hash);
+ });
+ // Add the button directly to the commit box
+ commitBox.appendChild(diffButton);
+
+ commitBoxesRow.appendChild(commitBox);
+ });
+
+ commitsContainer.appendChild(commitBoxesRow);
+ return commitsContainer;
+}
diff --git a/loop/webui/src/timeline/components/collapsible.ts b/loop/webui/src/timeline/components/collapsible.ts
new file mode 100644
index 0000000..12f90ec
--- /dev/null
+++ b/loop/webui/src/timeline/components/collapsible.ts
@@ -0,0 +1,37 @@
+import { TimelineMessage } from "../types";
+
+/**
+ * Adds collapsible functionality to long content elements.
+ * This creates a toggle button that allows users to expand/collapse long text content.
+ *
+ * @param message - The timeline message containing the content
+ * @param textEl - The DOM element containing the text content
+ * @param containerEl - The container element for the text and copy button
+ * @param contentEl - The outer content element that will contain everything
+ */
+export function addCollapsibleFunctionality(
+ message: TimelineMessage,
+ textEl: HTMLElement,
+ containerEl: HTMLElement,
+ contentEl: HTMLElement
+): void {
+ // Don't collapse end_of_turn messages (final output) regardless of length
+ if (message.content.length > 1000 && !message.end_of_turn) {
+ textEl.classList.add("collapsed");
+
+ const toggleButton = document.createElement("button");
+ toggleButton.className = "collapsible";
+ toggleButton.textContent = "Show more...";
+ toggleButton.addEventListener("click", () => {
+ textEl.classList.toggle("collapsed");
+ toggleButton.textContent = textEl.classList.contains("collapsed")
+ ? "Show more..."
+ : "Show less";
+ });
+
+ contentEl.appendChild(containerEl);
+ contentEl.appendChild(toggleButton);
+ } else {
+ contentEl.appendChild(containerEl);
+ }
+}
diff --git a/loop/webui/src/timeline/copybutton.ts b/loop/webui/src/timeline/copybutton.ts
new file mode 100644
index 0000000..d9b994b
--- /dev/null
+++ b/loop/webui/src/timeline/copybutton.ts
@@ -0,0 +1,44 @@
+/**
+ * Creates a copy button container with a functioning copy button
+ */
+export function createCopyButton(textToCopy: string): {
+ container: HTMLDivElement;
+ button: HTMLButtonElement;
+} {
+ // Create container for the copy button
+ const copyButtonContainer = document.createElement("div");
+ copyButtonContainer.className = "message-actions";
+
+ // Create the copy button itself
+ const copyButton = document.createElement("button");
+ copyButton.className = "copy-button";
+ copyButton.textContent = "Copy";
+ copyButton.title = "Copy text to clipboard";
+
+ // Add click event listener to handle copying
+ copyButton.addEventListener("click", (e) => {
+ e.stopPropagation();
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ copyButton.textContent = "Copied!";
+ setTimeout(() => {
+ copyButton.textContent = "Copy";
+ }, 2000);
+ })
+ .catch((err) => {
+ console.error("Failed to copy text: ", err);
+ copyButton.textContent = "Failed";
+ setTimeout(() => {
+ copyButton.textContent = "Copy";
+ }, 2000);
+ });
+ });
+
+ copyButtonContainer.appendChild(copyButton);
+
+ return {
+ container: copyButtonContainer,
+ button: copyButton
+ };
+}
diff --git a/loop/webui/src/timeline/data.ts b/loop/webui/src/timeline/data.ts
new file mode 100644
index 0000000..2130c21
--- /dev/null
+++ b/loop/webui/src/timeline/data.ts
@@ -0,0 +1,379 @@
+import { TimelineMessage } from "./types";
+import { formatNumber } from "./utils";
+
+/**
+ * Event types for data manager
+ */
+export type DataManagerEventType = 'dataChanged' | 'connectionStatusChanged';
+
+/**
+ * Connection status types
+ */
+export type ConnectionStatus = 'connected' | 'disconnected' | 'disabled';
+
+/**
+ * State interface
+ */
+export interface TimelineState {
+ hostname?: string;
+ working_dir?: string;
+ initial_commit?: string;
+ message_count?: number;
+ title?: string;
+ total_usage?: {
+ input_tokens: number;
+ output_tokens: number;
+ cache_read_input_tokens: number;
+ cache_creation_input_tokens: number;
+ total_cost_usd: number;
+ };
+}
+
+/**
+ * DataManager - Class to manage timeline data, fetching, and polling
+ */
+export class DataManager {
+ // State variables
+ private lastMessageCount: number = 0;
+ private nextFetchIndex: number = 0;
+ private currentFetchStartIndex: number = 0;
+ private currentPollController: AbortController | null = null;
+ private isFetchingMessages: boolean = false;
+ private isPollingEnabled: boolean = true;
+ private isFirstLoad: boolean = true;
+ private connectionStatus: ConnectionStatus = "disabled";
+ private messages: TimelineMessage[] = [];
+ private timelineState: TimelineState | null = null;
+
+ // Event listeners
+ private eventListeners: Map<DataManagerEventType, Array<(...args: any[]) => void>> = new Map();
+
+ constructor() {
+ // Initialize empty arrays for each event type
+ this.eventListeners.set('dataChanged', []);
+ this.eventListeners.set('connectionStatusChanged', []);
+ }
+
+ /**
+ * Initialize the data manager and fetch initial data
+ */
+ public async initialize(): Promise<void> {
+ try {
+ // Initial data fetch
+ await this.fetchData();
+ // Start polling for updates only if initial fetch succeeds
+ this.startPolling();
+ } catch (error) {
+ console.error("Initial data fetch failed, will retry via polling", error);
+ // Still start polling to recover
+ this.startPolling();
+ }
+ }
+
+ /**
+ * Get all messages
+ */
+ public getMessages(): TimelineMessage[] {
+ return this.messages;
+ }
+
+ /**
+ * Get the current state
+ */
+ public getState(): TimelineState | null {
+ return this.timelineState;
+ }
+
+ /**
+ * Get the connection status
+ */
+ public getConnectionStatus(): ConnectionStatus {
+ return this.connectionStatus;
+ }
+
+ /**
+ * Get the isFirstLoad flag
+ */
+ public getIsFirstLoad(): boolean {
+ return this.isFirstLoad;
+ }
+
+ /**
+ * Get the currentFetchStartIndex
+ */
+ public getCurrentFetchStartIndex(): number {
+ return this.currentFetchStartIndex;
+ }
+
+ /**
+ * Add an event listener
+ */
+ public addEventListener(event: DataManagerEventType, callback: (...args: any[]) => void): void {
+ const listeners = this.eventListeners.get(event) || [];
+ listeners.push(callback);
+ this.eventListeners.set(event, listeners);
+ }
+
+ /**
+ * Remove an event listener
+ */
+ public removeEventListener(event: DataManagerEventType, callback: (...args: any[]) => void): void {
+ const listeners = this.eventListeners.get(event) || [];
+ const index = listeners.indexOf(callback);
+ if (index !== -1) {
+ listeners.splice(index, 1);
+ this.eventListeners.set(event, listeners);
+ }
+ }
+
+ /**
+ * Emit an event
+ */
+ private emitEvent(event: DataManagerEventType, ...args: any[]): void {
+ const listeners = this.eventListeners.get(event) || [];
+ listeners.forEach(callback => callback(...args));
+ }
+
+ /**
+ * Set polling enabled/disabled state
+ */
+ public setPollingEnabled(enabled: boolean): void {
+ this.isPollingEnabled = enabled;
+
+ if (enabled) {
+ this.startPolling();
+ } else {
+ this.stopPolling();
+ }
+ }
+
+ /**
+ * Start polling for updates
+ */
+ public startPolling(): void {
+ this.stopPolling(); // Stop any existing polling
+
+ // Start long polling
+ this.longPoll();
+ }
+
+ /**
+ * Stop polling for updates
+ */
+ public stopPolling(): void {
+ // Abort any ongoing long poll request
+ if (this.currentPollController) {
+ this.currentPollController.abort();
+ this.currentPollController = null;
+ }
+
+ // If polling is disabled by user, set connection status to disabled
+ if (!this.isPollingEnabled) {
+ this.updateConnectionStatus("disabled");
+ }
+ }
+
+ /**
+ * Update the connection status
+ */
+ private updateConnectionStatus(status: ConnectionStatus): void {
+ if (this.connectionStatus !== status) {
+ this.connectionStatus = status;
+ this.emitEvent('connectionStatusChanged', status);
+ }
+ }
+
+ /**
+ * Long poll for updates
+ */
+ private async longPoll(): Promise<void> {
+ // Abort any existing poll request
+ if (this.currentPollController) {
+ this.currentPollController.abort();
+ this.currentPollController = null;
+ }
+
+ // If polling is disabled, don't start a new poll
+ if (!this.isPollingEnabled) {
+ return;
+ }
+
+ let timeoutId: number | undefined;
+
+ try {
+ // Create a new abort controller for this request
+ this.currentPollController = new AbortController();
+ const signal = this.currentPollController.signal;
+
+ // Get the URL with the current message count
+ const pollUrl = `state?poll=true&seen=${this.lastMessageCount}`;
+
+ // Make the long poll request
+ // Use explicit timeout to handle stalled connections (120s)
+ const controller = new AbortController();
+ timeoutId = window.setTimeout(() => controller.abort(), 120000);
+
+ interface CustomFetchOptions extends RequestInit {
+ [Symbol.toStringTag]?: unknown;
+ }
+
+ const fetchOptions: CustomFetchOptions = {
+ signal: controller.signal,
+ // Use the original signal to allow manual cancellation too
+ get [Symbol.toStringTag]() {
+ if (signal.aborted) controller.abort();
+ return "";
+ },
+ };
+
+ try {
+ const response = await fetch(pollUrl, fetchOptions);
+ // Clear the timeout since we got a response
+ clearTimeout(timeoutId);
+
+ // Parse the JSON response
+ const _data = await response.json();
+
+ // If we got here, data has changed, so fetch the latest data
+ await this.fetchData();
+
+ // Start a new long poll (if polling is still enabled)
+ if (this.isPollingEnabled) {
+ this.longPoll();
+ }
+ } catch (error) {
+ // Handle fetch errors inside the inner try block
+ clearTimeout(timeoutId);
+ throw error; // Re-throw to be caught by the outer catch block
+ }
+ } catch (error: unknown) {
+ // Clean up timeout if we're handling an error
+ if (timeoutId) clearTimeout(timeoutId);
+
+ // Don't log or treat manual cancellations as errors
+ const isErrorWithName = (
+ err: unknown,
+ ): err is { name: string; message?: string } =>
+ typeof err === "object" && err !== null && "name" in err;
+
+ if (
+ isErrorWithName(error) &&
+ error.name === "AbortError" &&
+ this.currentPollController?.signal.aborted
+ ) {
+ console.log("Polling cancelled by user");
+ return;
+ }
+
+ // Handle different types of errors with specific messages
+ let errorMessage = "Not connected";
+
+ if (isErrorWithName(error)) {
+ if (error.name === "AbortError") {
+ // This was our timeout abort
+ errorMessage = "Connection timeout - not connected";
+ console.error("Long polling timeout");
+ } else if (error.name === "SyntaxError") {
+ // JSON parsing error
+ errorMessage = "Invalid response from server - not connected";
+ console.error("JSON parsing error:", error);
+ } else if (
+ error.name === "TypeError" &&
+ error.message?.includes("NetworkError")
+ ) {
+ // Network connectivity issues
+ errorMessage = "Network connection lost - not connected";
+ console.error("Network error during polling:", error);
+ } else {
+ // Generic error
+ console.error("Long polling error:", error);
+ }
+ }
+
+ // Disable polling on error
+ this.isPollingEnabled = false;
+
+ // Update connection status to disconnected
+ this.updateConnectionStatus("disconnected");
+
+ // Emit an event that we're disconnected with the error message
+ this.emitEvent('connectionStatusChanged', this.connectionStatus, errorMessage);
+ }
+ }
+
+ /**
+ * Fetch timeline data
+ */
+ public async fetchData(): Promise<void> {
+ // If we're already fetching messages, don't start another fetch
+ if (this.isFetchingMessages) {
+ console.log("Already fetching messages, skipping request");
+ return;
+ }
+
+ this.isFetchingMessages = true;
+
+ try {
+ // Fetch state first
+ const stateResponse = await fetch("state");
+ const state = await stateResponse.json();
+ this.timelineState = state;
+
+ // Check if new messages are available
+ if (
+ state.message_count === this.lastMessageCount &&
+ this.lastMessageCount > 0
+ ) {
+ // No new messages, early return
+ this.isFetchingMessages = false;
+ this.emitEvent('dataChanged', { state, newMessages: [] });
+ return;
+ }
+
+ // Fetch messages with a start parameter
+ this.currentFetchStartIndex = this.nextFetchIndex;
+ const messagesResponse = await fetch(
+ `messages?start=${this.nextFetchIndex}`,
+ );
+ const newMessages = await messagesResponse.json() || [];
+
+ // Store messages in our array
+ if (this.nextFetchIndex === 0) {
+ // If this is the first fetch, replace the entire array
+ this.messages = [...newMessages];
+ } else {
+ // Otherwise append the new messages
+ this.messages = [...this.messages, ...newMessages];
+ }
+
+ // Update connection status to connected
+ this.updateConnectionStatus("connected");
+
+ // Update the last message index for next fetch
+ if (newMessages && newMessages.length > 0) {
+ this.nextFetchIndex += newMessages.length;
+ }
+
+ // Update the message count
+ this.lastMessageCount = state?.message_count ?? 0;
+
+ // Mark that we've completed first load
+ if (this.isFirstLoad) {
+ this.isFirstLoad = false;
+ }
+
+ // Emit an event that data has changed
+ this.emitEvent('dataChanged', { state, newMessages, isFirstFetch: this.nextFetchIndex === newMessages.length });
+ } catch (error) {
+ console.error("Error fetching data:", error);
+
+ // Update connection status to disconnected
+ this.updateConnectionStatus("disconnected");
+
+ // Emit an event that we're disconnected
+ this.emitEvent('connectionStatusChanged', this.connectionStatus, "Not connected");
+ } finally {
+ this.isFetchingMessages = false;
+ }
+ }
+}
diff --git a/loop/webui/src/timeline/diffviewer.ts b/loop/webui/src/timeline/diffviewer.ts
new file mode 100644
index 0000000..1460dc3
--- /dev/null
+++ b/loop/webui/src/timeline/diffviewer.ts
@@ -0,0 +1,384 @@
+import * as Diff2Html from "diff2html";
+
+/**
+ * Class to handle diff and commit viewing functionality in the timeline UI.
+ */
+export class DiffViewer {
+ // Current commit hash being viewed
+ private currentCommitHash: string = "";
+ // Selected line in the diff for commenting
+ private selectedDiffLine: string | null = null;
+ // Current view mode (needed for integration with TimelineManager)
+ private viewMode: string = "chat";
+
+ /**
+ * Constructor for DiffViewer
+ */
+ constructor() {}
+
+ /**
+ * Sets the current view mode
+ * @param mode The current view mode
+ */
+ public setViewMode(mode: string): void {
+ this.viewMode = mode;
+ }
+
+ /**
+ * Gets the current commit hash
+ * @returns The current commit hash
+ */
+ public getCurrentCommitHash(): string {
+ return this.currentCommitHash;
+ }
+
+ /**
+ * Sets the current commit hash
+ * @param hash The commit hash to set
+ */
+ public setCurrentCommitHash(hash: string): void {
+ this.currentCommitHash = hash;
+ }
+
+ /**
+ * Clears the current commit hash
+ */
+ public clearCurrentCommitHash(): void {
+ this.currentCommitHash = "";
+ }
+
+ /**
+ * Loads diff content and renders it using diff2html
+ * @param commitHash Optional commit hash to load diff for
+ */
+ public async loadDiff2HtmlContent(commitHash?: string): Promise<void> {
+ const diff2htmlContent = document.getElementById("diff2htmlContent");
+ const container = document.querySelector(".timeline-container");
+ if (!diff2htmlContent || !container) return;
+
+ try {
+ // Show loading state
+ diff2htmlContent.innerHTML = "Loading enhanced diff...";
+
+ // Add classes to container to allow full-width rendering
+ container.classList.add("diff2-active");
+ container.classList.add("diff-active");
+
+ // Use currentCommitHash if provided or passed from parameter
+ const hash = commitHash || this.currentCommitHash;
+
+ // Build the diff URL - include commit hash if specified
+ const diffUrl = hash ? `diff?commit=${hash}` : "diff";
+
+ // Fetch the diff from the server
+ const response = await fetch(diffUrl);
+
+ if (!response.ok) {
+ throw new Error(
+ `Server returned ${response.status}: ${response.statusText}`,
+ );
+ }
+
+ const diffText = await response.text();
+
+ if (!diffText || diffText.trim() === "") {
+ diff2htmlContent.innerHTML =
+ "<span style='color: #666; font-style: italic;'>No changes detected since conversation started.</span>";
+ return;
+ }
+
+ // Get the selected view format
+ const formatRadios = document.getElementsByName("diffViewFormat") as NodeListOf<HTMLInputElement>;
+ let outputFormat = "side-by-side"; // default
+
+ // Convert NodeListOf to Array to ensure [Symbol.iterator]() is available
+ Array.from(formatRadios).forEach(radio => {
+ if (radio.checked) {
+ outputFormat = radio.value as "side-by-side" | "line-by-line";
+ }
+ })
+
+ // Render the diff using diff2html
+ const diffHtml = Diff2Html.html(diffText, {
+ outputFormat: outputFormat as "side-by-side" | "line-by-line",
+ drawFileList: true,
+ matching: "lines",
+ // Make sure no unnecessary scrollbars in the nested containers
+ renderNothingWhenEmpty: false,
+ colorScheme: "light" as any, // Force light mode to match the rest of the UI
+ });
+
+ // Insert the generated HTML
+ diff2htmlContent.innerHTML = diffHtml;
+
+ // Add CSS styles to ensure we don't have double scrollbars
+ const d2hFiles = diff2htmlContent.querySelectorAll(".d2h-file-wrapper");
+ d2hFiles.forEach((file) => {
+ const contentElem = file.querySelector(".d2h-files-diff");
+ if (contentElem) {
+ // Remove internal scrollbar - the outer container will handle scrolling
+ (contentElem as HTMLElement).style.overflow = "visible";
+ (contentElem as HTMLElement).style.maxHeight = "none";
+ }
+ });
+
+ // Add click event handlers to each code line for commenting
+ this.setupDiff2LineComments();
+
+ // Setup event listeners for diff view format radio buttons
+ this.setupDiffViewFormatListeners();
+ } catch (error) {
+ console.error("Error loading diff2html content:", error);
+ const errorMessage =
+ error instanceof Error ? error.message : "Unknown error";
+ diff2htmlContent.innerHTML = `<span style='color: #dc3545;'>Error loading enhanced diff: ${errorMessage}</span>`;
+ }
+ }
+
+ /**
+ * Setup event listeners for diff view format radio buttons
+ */
+ private setupDiffViewFormatListeners(): void {
+ const formatRadios = document.getElementsByName("diffViewFormat") as NodeListOf<HTMLInputElement>;
+
+ // Convert NodeListOf to Array to ensure [Symbol.iterator]() is available
+ Array.from(formatRadios).forEach(radio => {
+ radio.addEventListener("change", () => {
+ // Reload the diff with the new format when radio selection changes
+ this.loadDiff2HtmlContent(this.currentCommitHash);
+ });
+ })
+ }
+
+ /**
+ * Setup handlers for diff2 code lines to enable commenting
+ */
+ private setupDiff2LineComments(): void {
+ const diff2htmlContent = document.getElementById("diff2htmlContent");
+ if (!diff2htmlContent) return;
+
+ console.log("Setting up diff2 line comments");
+
+ // Add plus buttons to each code line
+ this.addCommentButtonsToCodeLines();
+
+ // Use event delegation for handling clicks on plus buttons
+ diff2htmlContent.addEventListener("click", (event) => {
+ const target = event.target as HTMLElement;
+
+ // Only respond to clicks on the plus button
+ if (target.classList.contains("d2h-gutter-comment-button")) {
+ // Find the parent row first
+ const row = target.closest("tr");
+ if (!row) return;
+
+ // Then find the code line in that row
+ const codeLine = row.querySelector(".d2h-code-side-line") || row.querySelector(".d2h-code-line");
+ if (!codeLine) return;
+
+ // Get the line text content
+ const lineContent = codeLine.querySelector(".d2h-code-line-ctn");
+ if (!lineContent) return;
+
+ const lineText = lineContent.textContent?.trim() || "";
+
+ // Get file name to add context
+ const fileHeader = codeLine
+ .closest(".d2h-file-wrapper")
+ ?.querySelector(".d2h-file-name");
+ const fileName = fileHeader
+ ? fileHeader.textContent?.trim()
+ : "Unknown file";
+
+ // Get line number if available
+ const lineNumElem = codeLine
+ .closest("tr")
+ ?.querySelector(".d2h-code-side-linenumber");
+ const lineNum = lineNumElem ? lineNumElem.textContent?.trim() : "";
+ const lineInfo = lineNum ? `Line ${lineNum}: ` : "";
+
+ // Format the line for the comment box with file context and line number
+ const formattedLine = `${fileName} ${lineInfo}${lineText}`;
+
+ console.log("Comment button clicked for line: ", formattedLine);
+
+ // Open the comment box with this line
+ this.openDiffCommentBox(formattedLine, 0);
+
+ // Prevent event from bubbling up
+ event.stopPropagation();
+ }
+ });
+
+ // Handle text selection
+ let isSelecting = false;
+
+ diff2htmlContent.addEventListener("mousedown", () => {
+ isSelecting = false;
+ });
+
+ diff2htmlContent.addEventListener("mousemove", (event) => {
+ // If mouse is moving with button pressed, user is selecting text
+ if (event.buttons === 1) { // Primary button (usually left) is pressed
+ isSelecting = true;
+ }
+ });
+ }
+
+ /**
+ * Add plus buttons to each table row in the diff for commenting
+ */
+ private addCommentButtonsToCodeLines(): void {
+ const diff2htmlContent = document.getElementById("diff2htmlContent");
+ if (!diff2htmlContent) return;
+
+ // Target code lines first, then find their parent rows
+ const codeLines = diff2htmlContent.querySelectorAll(
+ ".d2h-code-side-line, .d2h-code-line"
+ );
+
+ // Create a Set to store unique rows to avoid duplicates
+ const rowsSet = new Set<HTMLElement>();
+
+ // Get all rows that contain code lines
+ codeLines.forEach(line => {
+ const row = line.closest('tr');
+ if (row) rowsSet.add(row as HTMLElement);
+ });
+
+ // Convert Set back to array for processing
+ const codeRows = Array.from(rowsSet);
+
+ codeRows.forEach((row) => {
+ const rowElem = row as HTMLElement;
+
+ // Skip info lines without actual code (e.g., "file added")
+ if (rowElem.querySelector(".d2h-info")) {
+ return;
+ }
+
+ // Find the code line number element (first TD in the row)
+ const lineNumberCell = rowElem.querySelector(
+ ".d2h-code-side-linenumber, .d2h-code-linenumber"
+ );
+
+ if (!lineNumberCell) return;
+
+ // Create the plus button
+ const plusButton = document.createElement("span");
+ plusButton.className = "d2h-gutter-comment-button";
+ plusButton.innerHTML = "+";
+ plusButton.title = "Add a comment on this line";
+
+ // Add button to the line number cell for proper positioning
+ (lineNumberCell as HTMLElement).style.position = "relative"; // Ensure positioning context
+ lineNumberCell.appendChild(plusButton);
+ });
+ }
+
+ /**
+ * Open the comment box for a selected diff line
+ */
+ private openDiffCommentBox(lineText: string, _lineNumber: number): void {
+ const commentBox = document.getElementById("diffCommentBox");
+ const selectedLine = document.getElementById("selectedLine");
+ const commentInput = document.getElementById(
+ "diffCommentInput",
+ ) as HTMLTextAreaElement;
+
+ if (!commentBox || !selectedLine || !commentInput) return;
+
+ // Store the selected line
+ this.selectedDiffLine = lineText;
+
+ // Display the line in the comment box
+ selectedLine.textContent = lineText;
+
+ // Reset the comment input
+ commentInput.value = "";
+
+ // Show the comment box
+ commentBox.style.display = "block";
+
+ // Focus on the comment input
+ commentInput.focus();
+
+ // Add event listeners for submit and cancel buttons
+ const submitButton = document.getElementById("submitDiffComment");
+ if (submitButton) {
+ submitButton.onclick = () => this.submitDiffComment();
+ }
+
+ const cancelButton = document.getElementById("cancelDiffComment");
+ if (cancelButton) {
+ cancelButton.onclick = () => this.closeDiffCommentBox();
+ }
+ }
+
+ /**
+ * Close the diff comment box without submitting
+ */
+ private closeDiffCommentBox(): void {
+ const commentBox = document.getElementById("diffCommentBox");
+ if (commentBox) {
+ commentBox.style.display = "none";
+ }
+ this.selectedDiffLine = null;
+ }
+
+ /**
+ * Submit a comment on a diff line
+ */
+ private submitDiffComment(): void {
+ const commentInput = document.getElementById(
+ "diffCommentInput",
+ ) as HTMLTextAreaElement;
+ const chatInput = document.getElementById(
+ "chatInput",
+ ) as HTMLTextAreaElement;
+
+ if (!commentInput || !chatInput) return;
+
+ const comment = commentInput.value.trim();
+
+ // Validate inputs
+ if (!this.selectedDiffLine || !comment) {
+ alert("Please select a line and enter a comment.");
+ return;
+ }
+
+ // Format the comment in a readable way
+ const formattedComment = `\`\`\`\n${this.selectedDiffLine}\n\`\`\`\n\n${comment}`;
+
+ // Append the formatted comment to the chat textarea
+ if (chatInput.value.trim() !== "") {
+ chatInput.value += "\n\n"; // Add two line breaks before the new comment
+ }
+ chatInput.value += formattedComment;
+ chatInput.focus();
+
+ // Close only the comment box but keep the diff view open
+ this.closeDiffCommentBox();
+ }
+
+ /**
+ * Show diff for a specific commit
+ * @param commitHash The commit hash to show diff for
+ * @param toggleViewModeCallback Callback to toggle view mode to diff
+ */
+ public showCommitDiff(commitHash: string, toggleViewModeCallback: (mode: string) => void): void {
+ // Store the commit hash
+ this.currentCommitHash = commitHash;
+
+ // Switch to diff2 view (side-by-side)
+ toggleViewModeCallback("diff2");
+ }
+
+ /**
+ * Clean up resources when component is destroyed
+ */
+ public dispose(): void {
+ // Clean up any resources or event listeners here
+ // Currently there are no specific resources to clean up
+ }
+}
diff --git a/loop/webui/src/timeline/icons/index.ts b/loop/webui/src/timeline/icons/index.ts
new file mode 100644
index 0000000..d9480c5
--- /dev/null
+++ b/loop/webui/src/timeline/icons/index.ts
@@ -0,0 +1,19 @@
+/**
+ * Get the icon text to display for a message type
+ * @param type - The message type
+ * @returns The single character to represent this message type
+ */
+export function getIconText(type: string | null | undefined): string {
+ switch (type) {
+ case "user":
+ return "U";
+ case "agent":
+ return "A";
+ case "tool":
+ return "T";
+ case "error":
+ return "E";
+ default:
+ return "?";
+ }
+}
diff --git a/loop/webui/src/timeline/index.ts b/loop/webui/src/timeline/index.ts
new file mode 100644
index 0000000..a3d24b7
--- /dev/null
+++ b/loop/webui/src/timeline/index.ts
@@ -0,0 +1,24 @@
+// Export types
+export * from './types';
+
+// Export utility functions
+export * from './utils';
+
+// Export terminal handler
+export * from './terminal';
+
+// Export diff viewer
+export * from './diffviewer';
+
+// Export chart manager
+export * from './charts';
+
+// Export tool call utilities
+export * from './toolcalls';
+
+// Export copy button utilities
+export * from './copybutton';
+
+// Re-export the timeline manager (will be implemented later)
+// For now, we'll maintain backward compatibility by importing from the original file
+import '../timeline';
diff --git a/loop/webui/src/timeline/markdown/renderer.ts b/loop/webui/src/timeline/markdown/renderer.ts
new file mode 100644
index 0000000..8199b69
--- /dev/null
+++ b/loop/webui/src/timeline/markdown/renderer.ts
@@ -0,0 +1,40 @@
+import { marked } from "marked";
+
+/**
+ * Renders markdown content as HTML with proper security handling.
+ *
+ * @param markdownContent - The markdown string to render
+ * @returns The rendered HTML content as a string
+ */
+export async function renderMarkdown(markdownContent: string): Promise<string> {
+ try {
+ // Set markdown options for proper code block highlighting and safety
+ const markedOptions = {
+ gfm: true, // GitHub Flavored Markdown
+ breaks: true, // Convert newlines to <br>
+ headerIds: false, // Disable header IDs for safety
+ mangle: false, // Don't mangle email addresses
+ // DOMPurify is recommended for production, but not included in this implementation
+ };
+
+ return await marked.parse(markdownContent, markedOptions);
+ } catch (error) {
+ console.error("Error rendering markdown:", error);
+ // Fallback to plain text if markdown parsing fails
+ return markdownContent;
+ }
+}
+
+/**
+ * Process rendered markdown HTML element, adding security attributes to links.
+ *
+ * @param element - The HTML element containing rendered markdown
+ */
+export function processRenderedMarkdown(element: HTMLElement): void {
+ // Make sure links open in a new tab and have proper security attributes
+ const links = element.querySelectorAll("a");
+ links.forEach((link) => {
+ link.setAttribute("target", "_blank");
+ link.setAttribute("rel", "noopener noreferrer");
+ });
+}
diff --git a/loop/webui/src/timeline/renderer.ts b/loop/webui/src/timeline/renderer.ts
new file mode 100644
index 0000000..f2770ee
--- /dev/null
+++ b/loop/webui/src/timeline/renderer.ts
@@ -0,0 +1,729 @@
+/**
+ * MessageRenderer - Class to handle rendering of timeline messages
+ */
+
+import { TimelineMessage, ToolCall } from "./types";
+import { escapeHTML, formatNumber, generateColorFromId } from "./utils";
+import { renderMarkdown, processRenderedMarkdown } from "./markdown/renderer";
+import { createToolCallCard, updateToolCallCard } from "./toolcalls";
+import { createCommitsContainer } from "./commits";
+import { createCopyButton } from "./copybutton";
+import { getIconText } from "./icons";
+import { addCollapsibleFunctionality } from "./components/collapsible";
+import { checkShouldScroll, scrollToBottom } from "./scroll";
+
+export class MessageRenderer {
+ // Map to store references to agent message DOM elements by tool call ID
+ private toolCallIdToMessageElement: Map<
+ string,
+ {
+ messageEl: HTMLElement;
+ toolCallContainer: HTMLElement | null;
+ toolCardId: string;
+ }
+ > = new Map();
+
+ // State tracking variables
+ private isFirstLoad: boolean = true;
+ private shouldScrollToBottom: boolean = true;
+ private currentFetchStartIndex: number = 0;
+
+ constructor() {}
+
+ /**
+ * Initialize the renderer with state from the timeline manager
+ */
+ public initialize(isFirstLoad: boolean, currentFetchStartIndex: number) {
+ this.isFirstLoad = isFirstLoad;
+ this.currentFetchStartIndex = currentFetchStartIndex;
+ }
+
+ /**
+ * Renders the timeline with messages
+ * @param messages The messages to render
+ * @param clearExisting Whether to clear existing content before rendering
+ */
+ public renderTimeline(
+ messages: TimelineMessage[],
+ clearExisting: boolean = false,
+ ): void {
+ const timeline = document.getElementById("timeline");
+ if (!timeline) return;
+
+ // We'll keep the isFirstLoad value for this render cycle,
+ // but will set it to false afterwards in scrollToBottom
+
+ if (clearExisting) {
+ timeline.innerHTML = ""; // Clear existing content only if this is the first load
+ // Clear our map of tool call references
+ this.toolCallIdToMessageElement.clear();
+ }
+
+ if (!messages || messages.length === 0) {
+ if (clearExisting) {
+ timeline.innerHTML = "<p>No messages available.</p>";
+ timeline.classList.add("empty");
+ }
+ return;
+ }
+
+ // Remove empty class when there are messages
+ timeline.classList.remove("empty");
+
+ // Keep track of conversation groups to properly indent
+ interface ConversationGroup {
+ color: string;
+ level: number;
+ }
+
+ const conversationGroups: Record<string, ConversationGroup> = {};
+
+ // Use the currentFetchStartIndex as the base index for these messages
+ const startIndex = this.currentFetchStartIndex;
+ // Group tool messages with their parent agent messages
+ const organizedMessages: (TimelineMessage & {
+ toolResponses?: TimelineMessage[];
+ })[] = [];
+ const toolMessagesByCallId: Record<string, TimelineMessage> = {};
+
+ // First, process tool messages - check if any can update existing UI elements
+ const processedToolMessages = new Set<string>();
+
+ messages.forEach((message) => {
+ // If this is a tool message with a tool_call_id
+ if (message.type === "tool" && message.tool_call_id) {
+ // Try to find an existing agent message that's waiting for this tool response
+ const toolCallRef = this.toolCallIdToMessageElement.get(
+ message.tool_call_id,
+ );
+
+ if (toolCallRef) {
+ // Found an existing agent message that needs updating
+ this.updateToolCallInAgentMessage(message, toolCallRef);
+ processedToolMessages.add(message.tool_call_id);
+ } else {
+ // No existing agent message found, we'll include this in normal rendering
+ toolMessagesByCallId[message.tool_call_id] = message;
+ }
+ }
+ });
+
+ // Then, process messages and organize them
+ messages.forEach((message, localIndex) => {
+ const _index = startIndex + localIndex;
+ if (!message) return; // Skip if message is null/undefined
+
+ // If it's a tool message and we're going to inline it with its parent agent message,
+ // we'll skip rendering it here - it will be included with the agent message
+ if (message.type === "tool" && message.tool_call_id) {
+ // Skip if we've already processed this tool message (updated an existing agent message)
+ if (processedToolMessages.has(message.tool_call_id)) {
+ return;
+ }
+
+ // Skip if this tool message will be included with a new agent message
+ if (toolMessagesByCallId[message.tool_call_id]) {
+ return;
+ }
+ }
+
+ // For agent messages with tool calls, attach their tool responses
+ if (
+ message.type === "agent" &&
+ message.tool_calls &&
+ message.tool_calls.length > 0
+ ) {
+ const toolResponses: TimelineMessage[] = [];
+
+ // Look up tool responses for each tool call
+ message.tool_calls.forEach((toolCall) => {
+ if (
+ toolCall.tool_call_id &&
+ toolMessagesByCallId[toolCall.tool_call_id]
+ ) {
+ toolResponses.push(toolMessagesByCallId[toolCall.tool_call_id]);
+ }
+ });
+
+ if (toolResponses.length > 0) {
+ message = { ...message, toolResponses };
+ }
+ }
+
+ organizedMessages.push(message);
+ });
+
+ let lastMessage:TimelineMessage|undefined;
+ if (messages && messages.length > 0 && startIndex > 0) {
+ lastMessage = messages[startIndex-1];
+ }
+
+ // Loop through organized messages and create timeline items
+ organizedMessages.forEach((message, localIndex) => {
+ const _index = startIndex + localIndex;
+ if (!message) return; // Skip if message is null/undefined
+
+ if (localIndex > 0) {
+ lastMessage = organizedMessages.at(localIndex-1);
+ }
+ // Determine if this is a subconversation
+ const hasParent = !!message.parent_conversation_id;
+ const conversationId = message.conversation_id || "";
+ const _parentId = message.parent_conversation_id || "";
+
+ // Track the conversation group
+ if (conversationId && !conversationGroups[conversationId]) {
+ conversationGroups[conversationId] = {
+ color: generateColorFromId(conversationId),
+ level: hasParent ? 1 : 0, // Level 0 for main conversation, 1+ for nested
+ };
+ }
+
+ // Get the level and color for this message
+ const group = conversationGroups[conversationId] || {
+ level: 0,
+ color: "#888888",
+ };
+
+ const messageEl = document.createElement("div");
+ messageEl.className = `message ${message.type || "unknown"} ${message.end_of_turn ? "end-of-turn" : ""}`;
+
+ // Add indentation class for subconversations
+ if (hasParent) {
+ messageEl.classList.add("subconversation");
+ messageEl.style.marginLeft = `${group.level * 40}px`;
+
+ // Add a colored left border to indicate the subconversation
+ messageEl.style.borderLeft = `4px solid ${group.color}`;
+ }
+
+ // newMsgType indicates when to create a new icon and message
+ // type header. This is a primitive form of message coalescing,
+ // but it does reduce the amount of redundant information in
+ // the UI.
+ const newMsgType = !lastMessage ||
+ (message.type == 'user' && lastMessage.type != 'user') ||
+ (message.type != 'user' && lastMessage.type == 'user');
+
+ if (newMsgType) {
+ // Create message icon
+ const iconEl = document.createElement("div");
+ iconEl.className = "message-icon";
+ iconEl.textContent = getIconText(message.type);
+ messageEl.appendChild(iconEl);
+ }
+
+ // Create message content container
+ const contentEl = document.createElement("div");
+ contentEl.className = "message-content";
+
+ // Create message header
+ const headerEl = document.createElement("div");
+ headerEl.className = "message-header";
+
+ if (newMsgType) {
+ const typeEl = document.createElement("span");
+ typeEl.className = "message-type";
+ typeEl.textContent = this.getTypeName(message.type);
+ headerEl.appendChild(typeEl);
+ }
+
+ // Add timestamp and usage info combined for agent messages at the top
+ if (message.timestamp) {
+ const timestampEl = document.createElement("span");
+ timestampEl.className = "message-timestamp";
+ timestampEl.textContent = this.formatTimestamp(message.timestamp);
+
+ // Add elapsed time if available
+ if (message.elapsed) {
+ timestampEl.textContent += ` (${(message.elapsed / 1e9).toFixed(2)}s)`;
+ }
+
+ // Add turn duration for end-of-turn messages
+ if (message.turnDuration && message.end_of_turn) {
+ timestampEl.textContent += ` [Turn: ${(message.turnDuration / 1e9).toFixed(2)}s]`;
+ }
+
+ // Add usage info inline for agent messages
+ if (
+ message.type === "agent" &&
+ message.usage &&
+ (message.usage.input_tokens > 0 ||
+ message.usage.output_tokens > 0 ||
+ message.usage.cost_usd > 0)
+ ) {
+ try {
+ // Safe get all values
+ const inputTokens = formatNumber(
+ message.usage.input_tokens ?? 0,
+ );
+ const cacheInput = message.usage.cache_read_input_tokens ?? 0;
+ const outputTokens = formatNumber(
+ message.usage.output_tokens ?? 0,
+ );
+ const messageCost = this.formatCurrency(
+ message.usage.cost_usd ?? 0,
+ "$0.0000", // Default format for message costs
+ true, // Use 4 decimal places for message-level costs
+ );
+
+ timestampEl.textContent += ` | In: ${inputTokens}`;
+ if (cacheInput > 0) {
+ timestampEl.textContent += ` [Cache: ${formatNumber(cacheInput)}]`;
+ }
+ timestampEl.textContent += ` Out: ${outputTokens} (${messageCost})`;
+ } catch (e) {
+ console.error("Error adding usage info to timestamp:", e);
+ }
+ }
+
+ headerEl.appendChild(timestampEl);
+ }
+
+ contentEl.appendChild(headerEl);
+
+ // Add message content
+ if (message.content) {
+ const containerEl = document.createElement("div");
+ containerEl.className = "message-text-container";
+
+ const textEl = document.createElement("div");
+ textEl.className = "message-text markdown-content";
+
+ // Render markdown content
+ // Handle the Promise returned by renderMarkdown
+ renderMarkdown(message.content).then(html => {
+ textEl.innerHTML = html;
+ processRenderedMarkdown(textEl);
+ });
+
+ // Add copy button
+ const { container: copyButtonContainer, button: copyButton } = createCopyButton(message.content);
+ containerEl.appendChild(copyButtonContainer);
+ containerEl.appendChild(textEl);
+
+ // Add collapse/expand for long content
+ addCollapsibleFunctionality(message, textEl, containerEl, contentEl);
+ }
+
+ // If the message has tool calls, show them in an ultra-compact row of boxes
+ if (message.tool_calls && message.tool_calls.length > 0) {
+ const toolCallsContainer = document.createElement("div");
+ toolCallsContainer.className = "tool-calls-container";
+
+ // Create a header row with tool count
+ const toolCallsHeaderRow = document.createElement("div");
+ toolCallsHeaderRow.className = "tool-calls-header";
+ // No header text - empty header
+ toolCallsContainer.appendChild(toolCallsHeaderRow);
+
+ // Create a container for the tool call cards
+ const toolCallsCardContainer = document.createElement("div");
+ toolCallsCardContainer.className = "tool-call-cards-container";
+
+ // Add each tool call as a card with response or spinner
+ message.tool_calls.forEach((toolCall: ToolCall, _index: number) => {
+ // Create a unique ID for this tool card
+ const toolCardId = `tool-card-${toolCall.tool_call_id || Math.random().toString(36).substring(2, 11)}`;
+
+ // Find the matching tool response if it exists
+ const toolResponse = message.toolResponses?.find(
+ (resp) => resp.tool_call_id === toolCall.tool_call_id,
+ );
+
+ // Use the extracted utility function to create the tool card
+ const toolCard = createToolCallCard(toolCall, toolResponse, toolCardId);
+
+ // Store reference to this element if it has a tool_call_id
+ if (toolCall.tool_call_id) {
+ this.toolCallIdToMessageElement.set(toolCall.tool_call_id, {
+ messageEl,
+ toolCallContainer: toolCallsCardContainer,
+ toolCardId,
+ });
+ }
+
+ // Add the card to the container
+ toolCallsCardContainer.appendChild(toolCard);
+ });
+
+ toolCallsContainer.appendChild(toolCallsCardContainer);
+ contentEl.appendChild(toolCallsContainer);
+ }
+ // If message is a commit message, display commits
+ if (
+ message.type === "commit" &&
+ message.commits &&
+ message.commits.length > 0
+ ) {
+ // Use the extracted utility function to create the commits container
+ const commitsContainer = createCommitsContainer(
+ message.commits,
+ (commitHash) => {
+ // This will need to be handled by the TimelineManager
+ const event = new CustomEvent('showCommitDiff', {
+ detail: { commitHash }
+ });
+ document.dispatchEvent(event);
+ }
+ );
+ contentEl.appendChild(commitsContainer);
+ }
+
+ // Tool messages are now handled inline with agent messages
+ // If we still see a tool message here, it means it's not associated with an agent message
+ // (this could be legacy data or a special case)
+ if (message.type === "tool") {
+ const toolDetailsEl = document.createElement("div");
+ toolDetailsEl.className = "tool-details standalone";
+
+ // Get tool input and result for display
+ let inputText = "";
+ try {
+ if (message.input) {
+ const parsedInput = JSON.parse(message.input);
+ // Format input compactly for simple inputs
+ inputText = JSON.stringify(parsedInput);
+ }
+ } catch (e) {
+ // Not valid JSON, use as-is
+ inputText = message.input || "";
+ }
+
+ const resultText = message.tool_result || "";
+ const statusEmoji = message.tool_error ? "❌" : "✅";
+ const toolName = message.tool_name || "Unknown";
+
+ // Determine if we can use super compact display (e.g., for bash command results)
+ // Use compact display for short inputs/outputs without newlines
+ const isSimpleCommand =
+ toolName === "bash" &&
+ inputText.length < 50 &&
+ resultText.length < 200 &&
+ !resultText.includes("\n");
+ const isCompact =
+ inputText.length < 50 &&
+ resultText.length < 100 &&
+ !resultText.includes("\n");
+
+ if (isSimpleCommand) {
+ // SUPER COMPACT VIEW FOR BASH: Display everything on a single line
+ const toolLineEl = document.createElement("div");
+ toolLineEl.className = "tool-compact-line";
+
+ // Create the compact bash display in format: "✅ bash({command}) → result"
+ try {
+ const parsed = JSON.parse(inputText);
+ const cmd = parsed.command || "";
+ toolLineEl.innerHTML = `${statusEmoji} <strong>${toolName}</strong>({"command":"${cmd}"}) → <span class="tool-result-inline">${resultText}</span>`;
+ } catch {
+ toolLineEl.innerHTML = `${statusEmoji} <strong>${toolName}</strong>(${inputText}) → <span class="tool-result-inline">${resultText}</span>`;
+ }
+
+ // Add copy button for result
+ const copyBtn = document.createElement("button");
+ copyBtn.className = "copy-inline-button";
+ copyBtn.textContent = "Copy";
+ copyBtn.title = "Copy result to clipboard";
+
+ copyBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ navigator.clipboard
+ .writeText(resultText)
+ .then(() => {
+ copyBtn.textContent = "Copied!";
+ setTimeout(() => {
+ copyBtn.textContent = "Copy";
+ }, 2000);
+ })
+ .catch((_err) => {
+ copyBtn.textContent = "Failed";
+ setTimeout(() => {
+ copyBtn.textContent = "Copy";
+ }, 2000);
+ });
+ });
+
+ toolLineEl.appendChild(copyBtn);
+ toolDetailsEl.appendChild(toolLineEl);
+ } else if (isCompact && !isSimpleCommand) {
+ // COMPACT VIEW: Display everything on one or two lines for other tool types
+ const toolLineEl = document.createElement("div");
+ toolLineEl.className = "tool-compact-line";
+
+ // Create the compact display in format: "✅ tool_name(input) → result"
+ let compactDisplay = `${statusEmoji} <strong>${toolName}</strong>(${inputText})`;
+
+ if (resultText) {
+ compactDisplay += ` → <span class="tool-result-inline">${resultText}</span>`;
+ }
+
+ toolLineEl.innerHTML = compactDisplay;
+
+ // Add copy button for result
+ const copyBtn = document.createElement("button");
+ copyBtn.className = "copy-inline-button";
+ copyBtn.textContent = "Copy";
+ copyBtn.title = "Copy result to clipboard";
+
+ copyBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ navigator.clipboard
+ .writeText(resultText)
+ .then(() => {
+ copyBtn.textContent = "Copied!";
+ setTimeout(() => {
+ copyBtn.textContent = "Copy";
+ }, 2000);
+ })
+ .catch((_err) => {
+ copyBtn.textContent = "Failed";
+ setTimeout(() => {
+ copyBtn.textContent = "Copy";
+ }, 2000);
+ });
+ });
+
+ toolLineEl.appendChild(copyBtn);
+ toolDetailsEl.appendChild(toolLineEl);
+ } else {
+ // EXPANDED VIEW: For longer inputs/results that need more space
+ // Tool name header
+ const toolNameEl = document.createElement("div");
+ toolNameEl.className = "tool-name";
+ toolNameEl.innerHTML = `${statusEmoji} <strong>${toolName}</strong>`;
+ toolDetailsEl.appendChild(toolNameEl);
+
+ // Show input (simplified)
+ if (message.input) {
+ const inputContainer = document.createElement("div");
+ inputContainer.className = "tool-input-container compact";
+
+ const inputEl = document.createElement("pre");
+ inputEl.className = "tool-input compact";
+ inputEl.textContent = inputText;
+ inputContainer.appendChild(inputEl);
+ toolDetailsEl.appendChild(inputContainer);
+ }
+
+ // Show result (simplified)
+ if (resultText) {
+ const resultContainer = document.createElement("div");
+ resultContainer.className = "tool-result-container compact";
+
+ const resultEl = document.createElement("pre");
+ resultEl.className = "tool-result compact";
+ resultEl.textContent = resultText;
+ resultContainer.appendChild(resultEl);
+
+ // Add collapse/expand for longer results
+ if (resultText.length > 100) {
+ resultEl.classList.add("collapsed");
+
+ const toggleButton = document.createElement("button");
+ toggleButton.className = "collapsible";
+ toggleButton.textContent = "Show more...";
+ toggleButton.addEventListener("click", () => {
+ resultEl.classList.toggle("collapsed");
+ toggleButton.textContent = resultEl.classList.contains(
+ "collapsed",
+ )
+ ? "Show more..."
+ : "Show less";
+ });
+
+ toolDetailsEl.appendChild(resultContainer);
+ toolDetailsEl.appendChild(toggleButton);
+ } else {
+ toolDetailsEl.appendChild(resultContainer);
+ }
+ }
+ }
+
+ contentEl.appendChild(toolDetailsEl);
+ }
+
+ // Add usage info if available with robust null handling - only for non-agent messages
+ if (
+ message.type !== "agent" && // Skip for agent messages as we've already added usage info at the top
+ message.usage &&
+ (message.usage.input_tokens > 0 ||
+ message.usage.output_tokens > 0 ||
+ message.usage.cost_usd > 0)
+ ) {
+ try {
+ const usageEl = document.createElement("div");
+ usageEl.className = "usage-info";
+
+ // Safe get all values
+ const inputTokens = formatNumber(
+ message.usage.input_tokens ?? 0,
+ );
+ const cacheInput = message.usage.cache_read_input_tokens ?? 0;
+ const outputTokens = formatNumber(
+ message.usage.output_tokens ?? 0,
+ );
+ const messageCost = this.formatCurrency(
+ message.usage.cost_usd ?? 0,
+ "$0.0000", // Default format for message costs
+ true, // Use 4 decimal places for message-level costs
+ );
+
+ // Create usage info display
+ usageEl.innerHTML = `
+ <span title="Input tokens">In: ${inputTokens}</span>
+ ${cacheInput > 0 ? `<span title="Cache tokens">[Cache: ${formatNumber(cacheInput)}]</span>` : ""}
+ <span title="Output tokens">Out: ${outputTokens}</span>
+ <span title="Message cost">(${messageCost})</span>
+ `;
+
+ contentEl.appendChild(usageEl);
+ } catch (e) {
+ console.error("Error rendering usage info:", e);
+ }
+ }
+
+ messageEl.appendChild(contentEl);
+ timeline.appendChild(messageEl);
+ });
+
+ // Scroll to bottom of the timeline if needed
+ this.scrollToBottom();
+ }
+
+ /**
+ * Check if we should scroll to the bottom
+ */
+ private checkShouldScroll(): boolean {
+ return checkShouldScroll(this.isFirstLoad);
+ }
+
+ /**
+ * Scroll to the bottom of the timeline
+ */
+ private scrollToBottom(): void {
+ scrollToBottom(this.shouldScrollToBottom);
+
+ // After first load, we'll only auto-scroll if user is already near the bottom
+ this.isFirstLoad = false;
+ }
+
+ /**
+ * Get readable name for message type
+ */
+ private getTypeName(type: string | null | undefined): string {
+ switch (type) {
+ case "user":
+ return "User";
+ case "agent":
+ return "Agent";
+ case "tool":
+ return "Tool Use";
+ case "error":
+ return "Error";
+ default:
+ return (
+ (type || "Unknown").charAt(0).toUpperCase() +
+ (type || "unknown").slice(1)
+ );
+ }
+ }
+
+ /**
+ * Format timestamp for display
+ */
+ private formatTimestamp(
+ timestamp: string | number | Date | null | undefined,
+ defaultValue: string = "",
+ ): string {
+ if (!timestamp) return defaultValue;
+ try {
+ const date = new Date(timestamp);
+ if (isNaN(date.getTime())) return defaultValue;
+
+ // Format: Mar 13, 2025 09:53:25 AM
+ return date.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: true,
+ });
+ } catch (e) {
+ return defaultValue;
+ }
+ }
+
+ /**
+ * Format currency values
+ */
+ private formatCurrency(
+ num: number | string | null | undefined,
+ defaultValue: string = "$0.00",
+ isMessageLevel: boolean = false,
+ ): string {
+ if (num === undefined || num === null) return defaultValue;
+ try {
+ // Use 4 decimal places for message-level costs, 2 for totals
+ const decimalPlaces = isMessageLevel ? 4 : 2;
+ return `$${parseFloat(String(num)).toFixed(decimalPlaces)}`;
+ } catch (e) {
+ return defaultValue;
+ }
+ }
+
+ /**
+ * Update a tool call in an agent message with the response
+ */
+ private updateToolCallInAgentMessage(
+ toolMessage: TimelineMessage,
+ toolCallRef: {
+ messageEl: HTMLElement;
+ toolCallContainer: HTMLElement | null;
+ toolCardId: string;
+ },
+ ): void {
+ const { messageEl, toolCardId } = toolCallRef;
+
+ // Find the tool card element
+ const toolCard = messageEl.querySelector(`#${toolCardId}`) as HTMLElement;
+ if (!toolCard) return;
+
+ // Use the extracted utility function to update the tool card
+ updateToolCallCard(toolCard, toolMessage);
+ }
+
+ /**
+ * Get the tool call id to message element map
+ * Used by the TimelineManager to access the map
+ */
+ public getToolCallIdToMessageElement(): Map<
+ string,
+ {
+ messageEl: HTMLElement;
+ toolCallContainer: HTMLElement | null;
+ toolCardId: string;
+ }
+ > {
+ return this.toolCallIdToMessageElement;
+ }
+
+ /**
+ * Set the tool call id to message element map
+ * Used by the TimelineManager to update the map
+ */
+ public setToolCallIdToMessageElement(
+ map: Map<
+ string,
+ {
+ messageEl: HTMLElement;
+ toolCallContainer: HTMLElement | null;
+ toolCardId: string;
+ }
+ >
+ ): void {
+ this.toolCallIdToMessageElement = map;
+ }
+}
diff --git a/loop/webui/src/timeline/scroll.ts b/loop/webui/src/timeline/scroll.ts
new file mode 100644
index 0000000..df3b8f9
--- /dev/null
+++ b/loop/webui/src/timeline/scroll.ts
@@ -0,0 +1,40 @@
+/**
+ * Check if the page should scroll to the bottom based on current view position
+ * @param isFirstLoad If this is the first load of the timeline
+ * @returns Boolean indicating if we should scroll to the bottom
+ */
+export function checkShouldScroll(isFirstLoad: boolean): boolean {
+ // Always scroll on first load
+ if (isFirstLoad) {
+ return true;
+ }
+
+ // Check if user is already near the bottom of the page
+ // Account for the fixed top bar and chat bar
+ return (
+ window.innerHeight + window.scrollY >= document.body.offsetHeight - 200
+ );
+}
+
+/**
+ * Scroll to the bottom of the timeline if shouldScrollToBottom is true
+ * @param shouldScrollToBottom Flag indicating if we should scroll
+ */
+export function scrollToBottom(shouldScrollToBottom: boolean): void {
+ // Find the timeline container
+ const timeline = document.getElementById("timeline");
+
+ // Scroll the window to the bottom based on our pre-determined value
+ if (timeline && shouldScrollToBottom) {
+ // Get the last message or element in the timeline
+ const lastElement = timeline.lastElementChild;
+
+ if (lastElement) {
+ // Scroll to the bottom of the page
+ window.scrollTo({
+ top: document.body.scrollHeight,
+ behavior: "smooth",
+ });
+ }
+ }
+}
diff --git a/loop/webui/src/timeline/terminal.ts b/loop/webui/src/timeline/terminal.ts
new file mode 100644
index 0000000..fbe9a7d
--- /dev/null
+++ b/loop/webui/src/timeline/terminal.ts
@@ -0,0 +1,269 @@
+import { Terminal } from "@xterm/xterm";
+import { FitAddon } from "@xterm/addon-fit";
+
+/**
+ * Class to handle terminal functionality in the timeline UI.
+ */
+export class TerminalHandler {
+ // Terminal instance
+ private terminal: Terminal | null = null;
+ // Terminal fit addon for handling resize
+ private fitAddon: FitAddon | null = null;
+ // Terminal EventSource for SSE
+ private terminalEventSource: EventSource | null = null;
+ // Terminal ID (always 1 for now, will support 1-9 later)
+ private terminalId: string = "1";
+ // Queue for serializing terminal inputs
+ private terminalInputQueue: string[] = [];
+ // Flag to track if we're currently processing a terminal input
+ private processingTerminalInput: boolean = false;
+ // Current view mode (needed for resize handling)
+ private viewMode: string = "chat";
+
+ /**
+ * Constructor for TerminalHandler
+ */
+ constructor() {}
+
+ /**
+ * Sets the current view mode
+ * @param mode The current view mode
+ */
+ public setViewMode(mode: string): void {
+ this.viewMode = mode;
+ }
+
+ /**
+ * Initialize the terminal component
+ * @param terminalContainer The DOM element to contain the terminal
+ */
+ public async initializeTerminal(): Promise<void> {
+ const terminalContainer = document.getElementById("terminalContainer");
+
+ if (!terminalContainer) {
+ console.error("Terminal container not found");
+ return;
+ }
+
+ // If terminal is already initialized, just focus it
+ if (this.terminal) {
+ this.terminal.focus();
+ if (this.fitAddon) {
+ this.fitAddon.fit();
+ }
+ return;
+ }
+
+ // Clear the terminal container
+ terminalContainer.innerHTML = "";
+
+ // Create new terminal instance
+ this.terminal = new Terminal({
+ cursorBlink: true,
+ theme: {
+ background: "#f5f5f5",
+ foreground: "#333333",
+ cursor: "#0078d7",
+ selectionBackground: "rgba(0, 120, 215, 0.4)",
+ },
+ });
+
+ // Add fit addon to handle terminal resizing
+ this.fitAddon = new FitAddon();
+ this.terminal.loadAddon(this.fitAddon);
+
+ // Open the terminal in the container
+ this.terminal.open(terminalContainer);
+
+ // Connect to WebSocket
+ await this.connectTerminal();
+
+ // Fit the terminal to the container
+ this.fitAddon.fit();
+
+ // Setup resize handler
+ window.addEventListener("resize", () => {
+ if (this.viewMode === "terminal" && this.fitAddon) {
+ this.fitAddon.fit();
+ // Send resize information to server
+ this.sendTerminalResize();
+ }
+ });
+
+ // Focus the terminal
+ this.terminal.focus();
+ }
+
+ /**
+ * Connect to terminal events stream
+ */
+ private async connectTerminal(): Promise<void> {
+ if (!this.terminal) {
+ return;
+ }
+
+ // Close existing connections if any
+ this.closeTerminalConnections();
+
+ try {
+ // Connect directly to the SSE endpoint for terminal 1
+ // Use relative URL based on current location
+ const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
+ const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
+ this.terminalEventSource = new EventSource(eventsUrl);
+
+ // Handle SSE events
+ this.terminalEventSource.onopen = () => {
+ console.log("Terminal SSE connection opened");
+ this.sendTerminalResize();
+ };
+
+ this.terminalEventSource.onmessage = (event) => {
+ if (this.terminal) {
+ // Decode base64 data before writing to terminal
+ try {
+ const decoded = atob(event.data);
+ this.terminal.write(decoded);
+ } catch (e) {
+ console.error('Error decoding terminal data:', e);
+ // Fallback to raw data if decoding fails
+ this.terminal.write(event.data);
+ }
+ }
+ };
+
+ this.terminalEventSource.onerror = (error) => {
+ console.error("Terminal SSE error:", error);
+ if (this.terminal) {
+ this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
+ }
+ // Attempt to reconnect if the connection was lost
+ if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
+ this.closeTerminalConnections();
+ }
+ };
+
+ // Send key inputs to the server via POST requests
+ if (this.terminal) {
+ this.terminal.onData((data) => {
+ this.sendTerminalInput(data);
+ });
+ }
+ } catch (error) {
+ console.error("Failed to connect to terminal:", error);
+ if (this.terminal) {
+ this.terminal.write(`\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`);
+ }
+ }
+ }
+
+ /**
+ * Close any active terminal connections
+ */
+ private closeTerminalConnections(): void {
+ if (this.terminalEventSource) {
+ this.terminalEventSource.close();
+ this.terminalEventSource = null;
+ }
+ }
+
+ /**
+ * Send input to the terminal
+ * @param data The input data to send
+ */
+ private async sendTerminalInput(data: string): Promise<void> {
+ // Add the data to the queue
+ this.terminalInputQueue.push(data);
+
+ // If we're not already processing inputs, start processing
+ if (!this.processingTerminalInput) {
+ await this.processTerminalInputQueue();
+ }
+ }
+
+ /**
+ * Process the terminal input queue in order
+ */
+ private async processTerminalInputQueue(): Promise<void> {
+ if (this.terminalInputQueue.length === 0) {
+ this.processingTerminalInput = false;
+ return;
+ }
+
+ this.processingTerminalInput = true;
+
+ // Concatenate all available inputs from the queue into a single request
+ let combinedData = '';
+
+ // Take all currently available items from the queue
+ while (this.terminalInputQueue.length > 0) {
+ combinedData += this.terminalInputQueue.shift()!;
+ }
+
+ try {
+ // Use relative URL based on current location
+ const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
+ const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
+ method: 'POST',
+ body: combinedData,
+ headers: {
+ 'Content-Type': 'text/plain'
+ }
+ });
+
+ if (!response.ok) {
+ console.error(`Failed to send terminal input: ${response.status} ${response.statusText}`);
+ }
+ } catch (error) {
+ console.error("Error sending terminal input:", error);
+ }
+
+ // Continue processing the queue (for any new items that may have been added)
+ await this.processTerminalInputQueue();
+ }
+
+ /**
+ * Send terminal resize information to the server
+ */
+ private async sendTerminalResize(): Promise<void> {
+ if (!this.terminal || !this.fitAddon) {
+ return;
+ }
+
+ // Get terminal dimensions
+ try {
+ // Send resize message in a format the server can understand
+ // Use relative URL based on current location
+ const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
+ const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
+ method: 'POST',
+ body: JSON.stringify({
+ type: "resize",
+ cols: this.terminal.cols || 80, // Default to 80 if undefined
+ rows: this.terminal.rows || 24, // Default to 24 if undefined
+ }),
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ if (!response.ok) {
+ console.error(`Failed to send terminal resize: ${response.status} ${response.statusText}`);
+ }
+ } catch (error) {
+ console.error("Error sending terminal resize:", error);
+ }
+ }
+
+ /**
+ * Clean up resources when component is destroyed
+ */
+ public dispose(): void {
+ this.closeTerminalConnections();
+ if (this.terminal) {
+ this.terminal.dispose();
+ this.terminal = null;
+ }
+ this.fitAddon = null;
+ }
+}
diff --git a/loop/webui/src/timeline/toolcalls.ts b/loop/webui/src/timeline/toolcalls.ts
new file mode 100644
index 0000000..5df88bd
--- /dev/null
+++ b/loop/webui/src/timeline/toolcalls.ts
@@ -0,0 +1,259 @@
+/**
+ * Utility functions for rendering tool calls in the timeline
+ */
+
+import { ToolCall, TimelineMessage } from "./types";
+import { html, render } from "lit-html";
+
+/**
+ * Create a tool call card element for display in the timeline
+ * @param toolCall The tool call data to render
+ * @param toolResponse Optional tool response message if available
+ * @param toolCardId Unique ID for this tool card
+ * @returns The created tool card element
+ */
+export function createToolCallCard(
+ toolCall: ToolCall,
+ toolResponse?: TimelineMessage | null,
+ toolCardId?: string
+): HTMLElement {
+ // Create a unique ID for this tool card if not provided
+ const cardId =
+ toolCardId ||
+ `tool-card-${
+ toolCall.tool_call_id || Math.random().toString(36).substring(2, 11)
+ }`;
+
+ // Get input as compact string
+ let inputText = "";
+ try {
+ if (toolCall.input) {
+ const parsedInput = JSON.parse(toolCall.input);
+
+ // For bash commands, use a special format
+ if (toolCall.name === "bash" && parsedInput.command) {
+ inputText = parsedInput.command;
+ } else {
+ // For other tools, use the stringified JSON
+ inputText = JSON.stringify(parsedInput);
+ }
+ }
+ } catch (e) {
+ // Not valid JSON, use as-is
+ inputText = toolCall.input || "";
+ }
+
+ // Truncate input text for display
+ const displayInput =
+ inputText.length > 80 ? inputText.substring(0, 78) + "..." : inputText;
+
+ // Truncate for compact display
+ const shortInput =
+ displayInput.length > 30
+ ? displayInput.substring(0, 28) + "..."
+ : displayInput;
+
+ // Format input for expanded view
+ let formattedInput = displayInput;
+ try {
+ const parsedInput = JSON.parse(toolCall.input || "");
+ formattedInput = JSON.stringify(parsedInput, null, 2);
+ } catch (e) {
+ // Not valid JSON, use display input as-is
+ }
+
+ // Truncate result for compact display if available
+ let shortResult = "";
+ if (toolResponse && toolResponse.tool_result) {
+ shortResult =
+ toolResponse.tool_result.length > 40
+ ? toolResponse.tool_result.substring(0, 38) + "..."
+ : toolResponse.tool_result;
+ }
+
+ // State for collapsed/expanded view
+ let isCollapsed = true;
+
+ // Handler to copy text to clipboard
+ const copyToClipboard = (text: string, button: HTMLElement) => {
+ navigator.clipboard
+ .writeText(text)
+ .then(() => {
+ button.textContent = "Copied!";
+ setTimeout(() => {
+ button.textContent = "Copy";
+ }, 2000);
+ })
+ .catch((err) => {
+ console.error("Failed to copy text:", err);
+ button.textContent = "Failed";
+ setTimeout(() => {
+ button.textContent = "Copy";
+ }, 2000);
+ });
+ };
+
+ const cancelToolCall = async(tool_call_id: string, button: HTMLButtonElement) => {
+ console.log('cancelToolCall', tool_call_id, button);
+ button.innerText = 'Cancelling';
+ button.disabled = true;
+ try {
+ const response = await fetch("cancel", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({tool_call_id: tool_call_id, reason: "user requested cancellation" }),
+ });
+ console.log('cancel', tool_call_id, response);
+ button.parentElement.removeChild(button);
+ } catch (e) {
+ console.error('cancel', tool_call_id,e);
+ }
+ };
+
+ // Create the container element
+ const container = document.createElement("div");
+ container.id = cardId;
+ container.className = "tool-call-card collapsed";
+
+ // Function to render the component
+ const renderComponent = () => {
+ const template = html`
+ <div
+ class="tool-call-compact-view"
+ @click=${() => {
+ isCollapsed = !isCollapsed;
+ container.classList.toggle("collapsed");
+ renderComponent();
+ }}
+ >
+ <span class="tool-call-status ${toolResponse ? "" : "spinner"}">
+ ${toolResponse ? (toolResponse.tool_error ? "❌" : "✅") : "⏳"}
+ </span>
+ <span class="tool-call-name">${toolCall.name}</span>
+ <code class="tool-call-input-preview">${shortInput}</code>
+ ${toolResponse && toolResponse.tool_result
+ ? html`<code class="tool-call-result-preview">${shortResult}</code>`
+ : ""}
+ ${toolResponse && toolResponse.elapsed !== undefined
+ ? html`<span class="tool-call-time"
+ >${(toolResponse.elapsed / 1e9).toFixed(2)}s</span
+ >`
+ : ""}
+ ${toolResponse ? "" :
+ html`<button class="refresh-button stop-button" title="Cancel this operation" @click=${(e: Event) => {
+ e.stopPropagation(); // Don't toggle expansion when clicking cancel
+ const button = e.target as HTMLButtonElement;
+ cancelToolCall(toolCall.tool_call_id, button);
+ }}>Cancel</button>`}
+ <span class="tool-call-expand-icon">${isCollapsed ? "▼" : "▲"}</span>
+ </div>
+
+ <div class="tool-call-expanded-view">
+ <div class="tool-call-section">
+ <div class="tool-call-section-label">
+ Input:
+ <button
+ class="tool-call-copy-btn"
+ title="Copy input to clipboard"
+ @click=${(e: Event) => {
+ e.stopPropagation(); // Don't toggle expansion when clicking copy
+ const button = e.target as HTMLElement;
+ copyToClipboard(toolCall.input || displayInput, button);
+ }}
+ >
+ Copy
+ </button>
+ </div>
+ <div class="tool-call-section-content">
+ <pre class="tool-call-input">${formattedInput}</pre>
+ </div>
+ </div>
+
+ ${toolResponse && toolResponse.tool_result
+ ? html`
+ <div class="tool-call-section">
+ <div class="tool-call-section-label">
+ Result:
+ <button
+ class="tool-call-copy-btn"
+ title="Copy result to clipboard"
+ @click=${(e: Event) => {
+ e.stopPropagation(); // Don't toggle expansion when clicking copy
+ const button = e.target as HTMLElement;
+ copyToClipboard(toolResponse.tool_result || "", button);
+ }}
+ >
+ Copy
+ </button>
+ </div>
+ <div class="tool-call-section-content">
+ <div class="tool-call-result">
+ ${toolResponse.tool_result.includes("\n")
+ ? html`<pre><code>${toolResponse.tool_result}</code></pre>`
+ : toolResponse.tool_result}
+ </div>
+ </div>
+ </div>
+ `
+ : ""}
+ </div>
+ `;
+
+ render(template, container);
+ };
+
+ // Initial render
+ renderComponent();
+
+ return container;
+}
+
+/**
+ * Update a tool call card with response data
+ * @param toolCard The tool card element to update
+ * @param toolMessage The tool response message
+ */
+export function updateToolCallCard(
+ toolCard: HTMLElement,
+ toolMessage: TimelineMessage
+): void {
+ if (!toolCard) return;
+
+ // Find the original tool call data to reconstruct the card
+ const toolName = toolCard.querySelector(".tool-call-name")?.textContent || "";
+ const inputPreview =
+ toolCard.querySelector(".tool-call-input-preview")?.textContent || "";
+
+ // Extract the original input from the expanded view
+ let originalInput = "";
+ const inputEl = toolCard.querySelector(".tool-call-input");
+ if (inputEl) {
+ originalInput = inputEl.textContent || "";
+ }
+
+ // Create a minimal ToolCall object from the existing data
+ const toolCall: Partial<ToolCall> = {
+ name: toolName,
+ // Try to reconstruct the original input if possible
+ input: originalInput,
+ };
+
+ // Replace the existing card with a new one
+ const newCard = createToolCallCard(
+ toolCall as ToolCall,
+ toolMessage,
+ toolCard.id
+ );
+
+ // Preserve the collapse state
+ if (!toolCard.classList.contains("collapsed")) {
+ newCard.classList.remove("collapsed");
+ }
+
+ // Replace the old card with the new one
+ if (toolCard.parentNode) {
+ toolCard.parentNode.replaceChild(newCard, toolCard);
+ }
+}
diff --git a/loop/webui/src/timeline/types.ts b/loop/webui/src/timeline/types.ts
new file mode 100644
index 0000000..81d47d0
--- /dev/null
+++ b/loop/webui/src/timeline/types.ts
@@ -0,0 +1,49 @@
+/**
+ * Interface for a Git commit
+ */
+export interface GitCommit {
+ hash: string; // Full commit hash
+ subject: string; // Commit subject line
+ body: string; // Full commit message body
+ pushed_branch?: string; // If set, this commit was pushed to this branch
+}
+
+/**
+ * Interface for a tool call
+ */
+export interface ToolCall {
+ name: string;
+ args?: string;
+ result?: string;
+ input?: string; // Input property for TypeScript compatibility
+ tool_call_id?: string;
+}
+
+/**
+ * Interface for a timeline message
+ */
+export interface TimelineMessage {
+ type: string;
+ content?: string;
+ timestamp?: string | number | Date;
+ elapsed?: number;
+ turnDuration?: number; // Turn duration field
+ end_of_turn?: boolean;
+ conversation_id?: string;
+ parent_conversation_id?: string;
+ tool_calls?: ToolCall[];
+ tool_name?: string;
+ tool_error?: boolean;
+ tool_call_id?: string;
+ commits?: GitCommit[]; // For commit messages
+ input?: string; // Input property
+ tool_result?: string; // Tool result property
+ toolResponses?: any[]; // Tool responses array
+ usage?: {
+ input_tokens?: number;
+ output_tokens?: number;
+ cache_read_input_tokens?: number;
+ cache_creation_input_tokens?: number;
+ cost_usd?: number;
+ };
+}
diff --git a/loop/webui/src/timeline/utils.ts b/loop/webui/src/timeline/utils.ts
new file mode 100644
index 0000000..ff505f9
--- /dev/null
+++ b/loop/webui/src/timeline/utils.ts
@@ -0,0 +1,50 @@
+/**
+ * Escapes HTML special characters in a string
+ */
+export function escapeHTML(str: string): string {
+ return str
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+/**
+ * Formats a number with locale-specific formatting
+ */
+export function formatNumber(
+ num: number | null | undefined,
+ defaultValue: string = "0",
+): string {
+ if (num === undefined || num === null) return defaultValue;
+ try {
+ return num.toLocaleString();
+ } catch (e) {
+ return String(num);
+ }
+}
+
+/**
+ * Generates a consistent color based on an ID string
+ */
+export function generateColorFromId(id: string | null | undefined): string {
+ if (!id) return "#7c7c7c"; // Default color for null/undefined
+
+ // Generate a hash from the ID
+ let hash = 0;
+ for (let i = 0; i < id.length; i++) {
+ hash = id.charCodeAt(i) + ((hash << 5) - hash);
+ }
+
+ // Convert hash to a hex color
+ let color = "#";
+ for (let i = 0; i < 3; i++) {
+ // Generate more muted colors by using only part of the range
+ // and adding a base value to avoid very dark colors
+ const value = ((hash >> (i * 8)) & 0xff);
+ const scaledValue = Math.floor(100 + (value * 100) / 255); // Range 100-200 for more muted colors
+ color += scaledValue.toString(16).padStart(2, "0");
+ }
+ return color;
+}
diff --git a/loop/webui/src/vega-types.d.ts b/loop/webui/src/vega-types.d.ts
new file mode 100644
index 0000000..97a4655
--- /dev/null
+++ b/loop/webui/src/vega-types.d.ts
@@ -0,0 +1,34 @@
+// Type definitions for Vega-Lite and related modules
+declare module "fast-json-patch/index.mjs";
+
+// Add any interface augmentations for TimelineMessage and ToolCall
+interface ToolCall {
+ name: string;
+ args?: string;
+ result?: string;
+ input?: string; // Add missing property
+}
+
+interface TimelineMessage {
+ type: string;
+ content?: string;
+ timestamp?: string | number | Date;
+ elapsed?: number;
+ end_of_turn?: boolean;
+ conversation_id?: string;
+ parent_conversation_id?: string;
+ tool_calls?: ToolCall[];
+ tool_name?: string;
+ tool_error?: boolean;
+ tool_result?: string;
+ input?: string;
+ start_time?: string | number | Date; // Add start time
+ end_time?: string | number | Date; // Add end time
+ usage?: {
+ input_tokens?: number;
+ output_tokens?: number;
+ cache_read_input_tokens?: number;
+ cache_creation_input_tokens?: number;
+ cost_usd?: number;
+ };
+}