diff --git a/src/App.scoped.css b/src/App.scoped.css
new file mode 100644
index 000000000..60bcf090b
--- /dev/null
+++ b/src/App.scoped.css
@@ -0,0 +1,1037 @@
+@reference "tailwindcss";
+
+.sidebar-root {
+ @apply h-full flex flex-col select-none;
+}
+
+.sidebar-root input,
+.sidebar-root textarea {
+ @apply select-text;
+}
+
+.sidebar-scrollable {
+ @apply flex-1 min-h-0 overflow-y-auto py-4 px-2 flex flex-col gap-2;
+}
+
+.content-root {
+ @apply h-full min-h-0 min-w-0 w-full flex flex-col overflow-y-hidden overflow-x-hidden bg-white;
+}
+
+.content-root.is-virtual-keyboard-open {
+ height: var(--visual-viewport-height);
+ max-height: var(--visual-viewport-height);
+ transform: translateY(var(--visual-viewport-offset-top));
+}
+
+.sidebar-thread-controls-host {
+ @apply mt-1 -translate-y-px px-2 pb-1;
+}
+
+.sidebar-search-toggle {
+ @apply h-6.75 w-6.75 rounded-md border border-transparent bg-transparent text-zinc-600 flex items-center justify-center transition hover:border-zinc-200 hover:bg-zinc-50;
+}
+
+.sidebar-search-toggle[aria-pressed='true'] {
+ @apply border-zinc-300 bg-zinc-100 text-zinc-700;
+}
+
+.sidebar-search-toggle-icon {
+ @apply w-4 h-4;
+}
+
+.sidebar-search-bar {
+ @apply flex items-center gap-1.5 mx-2 px-2 py-1 rounded-md border border-zinc-200 bg-white transition-colors focus-within:border-zinc-400;
+}
+
+.sidebar-search-bar-icon {
+ @apply w-3.5 h-3.5 text-zinc-400 shrink-0;
+}
+
+.sidebar-search-input {
+ @apply flex-1 min-w-0 bg-transparent text-sm text-zinc-800 placeholder-zinc-400 outline-none border-none p-0;
+}
+
+.sidebar-search-clear {
+ @apply w-4 h-4 rounded text-zinc-400 flex items-center justify-center transition hover:text-zinc-600;
+}
+
+.sidebar-search-clear-icon {
+ @apply w-3.5 h-3.5;
+}
+
+.sidebar-skills-link {
+ @apply mx-2 flex items-center gap-3 rounded-2xl border border-transparent bg-transparent px-3 py-2.5 text-left text-zinc-700 transition hover:bg-zinc-100 hover:text-zinc-950 cursor-pointer;
+}
+
+.sidebar-skills-link.is-active {
+ @apply border-transparent bg-zinc-100 text-zinc-950;
+}
+
+.sidebar-skills-link-icon {
+ @apply flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-emerald-600 text-white;
+}
+
+.sidebar-automations-link-icon {
+ @apply bg-amber-500;
+}
+
+.sidebar-skills-link-icon :deep(svg) {
+ @apply h-5 w-5;
+}
+
+.sidebar-skills-link-copy {
+ @apply flex min-w-0 flex-col;
+}
+
+.sidebar-skills-link-title {
+ @apply truncate text-sm font-semibold leading-5 tracking-[-0.01em];
+}
+
+.sidebar-skills-link-subtitle {
+ @apply truncate text-[11px] font-medium uppercase tracking-[0.18em] text-zinc-500;
+}
+
+.sidebar-thread-controls-header-host {
+ @apply ml-1;
+}
+
+.skills-route-header-icon {
+ @apply flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-emerald-600 text-white shadow-[0_16px_32px_-20px_rgba(5,150,105,0.9)];
+}
+
+.automations-route-header-icon {
+ @apply bg-amber-500 shadow-[0_16px_32px_-20px_rgba(245,158,11,0.9)];
+}
+
+.skills-route-header-icon :deep(svg) {
+ @apply h-4.5 w-4.5;
+}
+
+:global(:root.dark) .sidebar-skills-link-title {
+ @apply text-zinc-50;
+}
+
+:global(:root.dark) .sidebar-skills-link-subtitle {
+ @apply text-zinc-400;
+}
+
+.content-body {
+ @apply flex-1 min-h-0 min-w-0 w-full flex flex-col gap-2 sm:gap-3 pt-1 pb-2 sm:pb-4 overflow-x-hidden;
+}
+
+.content-root.is-virtual-keyboard-open .content-body {
+ padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
+}
+
+.content-root.is-virtual-keyboard-open .content-grid {
+ gap: 0.5rem;
+}
+
+.content-root.is-virtual-keyboard-open .content-thread {
+ min-height: 0;
+}
+
+.content-root.is-virtual-keyboard-open .composer-with-queue {
+ gap: 0.375rem;
+ padding-bottom: max(0.25rem, env(safe-area-inset-bottom));
+}
+
+.content-root.is-virtual-keyboard-open .content-thread-terminal-panel {
+ min-height: 0;
+}
+
+.content-root.is-virtual-keyboard-open .content-keyboard-spacer {
+ display: none;
+}
+
+
+
+.content-error {
+ @apply m-0 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700;
+}
+
+.content-grid {
+ @apply flex-1 min-h-0 flex flex-col gap-3;
+}
+
+.content-grid-home {
+ @apply overflow-y-auto;
+}
+
+.content-thread {
+ @apply flex-1 min-h-0;
+}
+
+.composer-with-queue {
+ @apply w-full shrink-0 px-2 sm:px-6 flex flex-col gap-2;
+}
+
+.content-thread-terminal-panel {
+ @apply w-full;
+}
+
+.content-header-terminal-command {
+ @apply max-w-48;
+}
+
+.content-header-terminal-command :deep(.composer-dropdown-trigger) {
+ @apply h-8 rounded-full border border-zinc-200 bg-white px-3 text-xs text-zinc-700 outline-none transition hover:bg-zinc-50 focus:border-zinc-300;
+}
+
+.content-header-terminal-command :deep(.composer-dropdown-prefix-icon) {
+ @apply h-4 w-4 text-zinc-500;
+}
+
+.content-header-terminal-command.is-open :deep(.composer-dropdown-trigger) {
+ @apply border-zinc-300 bg-zinc-100 text-zinc-950;
+}
+
+.content-header-terminal-command :deep(.composer-dropdown-menu-wrap) {
+ left: auto;
+ right: 0;
+}
+
+.content-header-terminal-command :deep(.composer-dropdown-menu) {
+ width: min(18rem, calc(100vw - 1rem));
+ min-width: min(14rem, calc(100vw - 1rem));
+}
+
+.content-header-terminal-command :deep(.composer-dropdown-option) {
+ @apply block truncate;
+}
+
+.content-header-terminal-command :deep(.composer-dropdown-trigger) {
+ @apply rounded-full border border-zinc-200 bg-white px-2.5 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-50;
+}
+
+.content-header-terminal-command :deep(.composer-dropdown-prefix-icon),
+.content-header-branch-dropdown :deep(.composer-dropdown-prefix-icon) {
+ @apply h-4 w-4 text-zinc-600;
+}
+
+.content-header-terminal-command :deep(.composer-dropdown-trigger),
+.content-header-branch-dropdown :deep(.composer-dropdown-trigger) {
+ @apply gap-0.5;
+}
+
+.content-header-branch-dropdown :deep(.composer-dropdown-trigger) {
+ @apply rounded-full border border-zinc-200 bg-white px-2.5 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-50;
+}
+
+.content-header-branch-dropdown :deep(.composer-dropdown-value) {
+ @apply max-w-40 truncate;
+}
+
+.content-header-branch-dropdown :deep(.composer-dropdown-menu-wrap) {
+ left: auto;
+ right: 0;
+}
+
+.content-header-branch-dropdown.is-review-open :deep(.composer-dropdown-trigger) {
+ @apply border-zinc-900 bg-zinc-900 text-white hover:bg-zinc-800;
+}
+
+.content-header-branch-dropdown.is-review-open :deep(.composer-dropdown-chevron) {
+ @apply text-white;
+}
+
+.new-thread-empty {
+ @apply flex-1 min-h-0 flex flex-col items-center justify-center gap-0.5 px-3 sm:px-6;
+}
+
+.new-thread-hero {
+ @apply m-0 text-2xl sm:text-[2.5rem] font-normal leading-[1.05] text-zinc-900;
+}
+
+.new-thread-folder-dropdown {
+ @apply text-2xl sm:text-[2.5rem] text-zinc-500;
+}
+
+.new-thread-folder-dropdown :deep(.composer-dropdown-trigger) {
+ @apply h-auto p-0 text-2xl sm:text-[2.5rem] leading-[1.05];
+}
+
+.new-thread-folder-dropdown :deep(.composer-dropdown-value) {
+ @apply leading-[1.05];
+}
+
+.new-thread-folder-dropdown :deep(.composer-dropdown-chevron) {
+ @apply h-4 w-4 sm:h-5 sm:w-5 mt-0;
+}
+
+.new-thread-folder-selected {
+ @apply mt-2 mb-0 max-w-3xl text-center text-xs text-zinc-500 break-all;
+}
+
+.new-thread-folder-actions {
+ @apply mt-3 flex w-full max-w-3xl flex-wrap items-center justify-center gap-2;
+}
+
+.new-thread-launch-card {
+ @apply mt-4 w-full max-w-3xl rounded-[28px] border border-emerald-200 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.2),_transparent_42%),linear-gradient(135deg,_#f4fff8,_#ffffff_58%)] px-5 py-5 text-left shadow-[0_18px_50px_-28px_rgba(5,150,105,0.45)];
+}
+
+.new-thread-launch-card-copy {
+ @apply flex flex-col gap-2;
+}
+
+.new-thread-launch-card-topline {
+ @apply flex items-center gap-2;
+}
+
+.new-thread-launch-card-badge {
+ @apply flex h-8 w-8 shrink-0 items-center justify-center rounded-2xl bg-emerald-700 text-white shadow-[0_12px_28px_-18px_rgba(5,150,105,0.9)];
+}
+
+.new-thread-launch-card-badge :deep(svg) {
+ @apply h-4 w-4;
+}
+
+.new-thread-launch-card-eyebrow {
+ @apply m-0 text-[11px] font-semibold uppercase tracking-[0.24em] text-emerald-700;
+}
+
+.new-thread-launch-card-title {
+ @apply m-0 text-xl font-semibold leading-tight text-zinc-950 sm:text-2xl;
+}
+
+.new-thread-launch-card-text {
+ @apply m-0 max-w-2xl text-sm leading-6 text-zinc-700 sm:text-[15px];
+}
+
+.new-thread-launch-card-actions {
+ @apply mt-4 flex flex-wrap items-center gap-2;
+}
+
+.new-thread-launch-card-pills {
+ @apply mt-1 flex flex-wrap gap-2;
+}
+
+.new-thread-launch-card-pill {
+ @apply inline-flex items-center rounded-full border border-emerald-100 bg-white/80 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-emerald-700;
+}
+
+.new-thread-launch-card-button {
+ @apply inline-flex h-10 items-center justify-center rounded-full border border-zinc-200 bg-white px-4 text-sm font-medium text-zinc-700 transition hover:bg-zinc-50;
+}
+
+.new-thread-launch-card-button-primary {
+ @apply border-emerald-700 bg-emerald-700 text-white hover:bg-emerald-600;
+}
+
+:global(:root.dark) .new-thread-launch-card {
+ @apply border-emerald-900/80 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.2),_transparent_38%),linear-gradient(135deg,_rgba(6,78,59,0.32),_rgba(24,24,27,0.96)_58%)] shadow-[0_24px_64px_-34px_rgba(16,185,129,0.35)];
+}
+
+:global(:root.dark) .new-thread-launch-card-eyebrow {
+ @apply text-emerald-300;
+}
+
+:global(:root.dark) .new-thread-launch-card-badge {
+ @apply bg-emerald-500 text-white;
+}
+
+:global(:root.dark) .new-thread-launch-card-title {
+ @apply text-zinc-50;
+}
+
+:global(:root.dark) .new-thread-launch-card-text {
+ @apply text-zinc-300;
+}
+
+:global(:root.dark) .new-thread-launch-card-pill {
+ @apply border-emerald-900 bg-zinc-900/70 text-emerald-300;
+}
+
+:global(:root.dark) .new-thread-launch-card-button {
+ @apply border-zinc-700 bg-zinc-900 text-zinc-100 hover:bg-zinc-800;
+}
+
+:global(:root.dark) .new-thread-launch-card-button-primary {
+ @apply border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-500;
+}
+
+.new-thread-folder-action {
+ @apply inline-flex h-9 items-center justify-center rounded-full border border-zinc-200 bg-white px-4 text-sm font-medium text-zinc-700 transition hover:bg-zinc-50 disabled:cursor-default disabled:opacity-60;
+}
+
+.new-thread-folder-action-primary {
+ @apply border-zinc-900 bg-zinc-900 text-white hover:bg-zinc-800;
+}
+
+.new-thread-open-folder-overlay {
+ @apply fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4;
+}
+
+.new-thread-open-folder {
+ @apply flex w-full max-w-3xl max-h-[90vh] flex-col gap-2 overflow-y-auto rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-xl;
+}
+
+.new-thread-project-modal {
+ @apply flex w-full max-w-xl max-h-[90vh] flex-col gap-3 overflow-y-auto rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-xl;
+}
+
+.new-thread-open-folder-header {
+ @apply flex items-center justify-between gap-3;
+}
+
+.new-thread-open-folder-title {
+ @apply m-0 text-sm font-semibold text-zinc-900;
+}
+
+.new-thread-open-folder-close {
+ @apply border-0 bg-transparent p-0 text-sm text-zinc-500 transition hover:text-zinc-800;
+}
+
+.new-thread-open-folder-label {
+ @apply m-0 text-xs font-medium uppercase tracking-wide text-zinc-500;
+}
+
+.new-thread-open-folder-current {
+ @apply flex items-start gap-2;
+}
+
+.new-thread-open-folder-path {
+ @apply min-w-0 flex-1 rounded-xl border border-zinc-200 bg-white px-3 py-2 font-mono text-xs text-zinc-700 outline-none transition focus:border-zinc-400;
+}
+
+.new-thread-open-folder-actions {
+ @apply flex flex-wrap items-center gap-2;
+}
+
+.new-thread-project-mode-tabs {
+ @apply grid grid-cols-2 rounded-xl border border-zinc-200 bg-zinc-50 p-1;
+}
+
+.new-thread-project-mode-tab {
+ @apply inline-flex h-9 items-center justify-center rounded-lg border-0 bg-transparent px-3 text-sm font-medium text-zinc-600 transition hover:bg-white hover:text-zinc-900 disabled:cursor-default disabled:opacity-60;
+}
+
+.new-thread-project-mode-tab.is-active {
+ @apply bg-white text-zinc-950 shadow-sm;
+}
+
+.new-thread-project-field {
+ @apply flex flex-col gap-1.5;
+}
+
+.new-thread-project-modal-actions {
+ @apply mt-1 flex flex-wrap justify-end gap-2;
+}
+
+.new-thread-open-folder-toggle {
+ @apply inline-flex items-center gap-2 text-sm text-zinc-600;
+}
+
+.new-thread-open-folder-toggle-input {
+ @apply relative h-4 w-4 shrink-0 appearance-none rounded-[4px] border border-zinc-300 bg-white outline-none transition;
+}
+
+.new-thread-open-folder-toggle-input:focus-visible {
+ box-shadow: 0 0 0 3px rgb(228 228 231);
+}
+
+.new-thread-open-folder-toggle-input:checked {
+ border-color: rgb(24 24 27);
+ background-color: rgb(255 255 255);
+}
+
+.new-thread-open-folder-toggle-input::after {
+ content: '';
+ position: absolute;
+ left: 4px;
+ top: 1px;
+ width: 4px;
+ height: 8px;
+ border-right: 2px solid rgb(24 24 27);
+ border-bottom: 2px solid rgb(24 24 27);
+ transform: rotate(45deg);
+ opacity: 0;
+}
+
+.new-thread-open-folder-toggle-input:checked::after {
+ opacity: 1;
+}
+
+.new-thread-open-folder-filter {
+ @apply w-full rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 outline-none transition focus:border-zinc-400;
+}
+
+.new-thread-open-folder-create {
+ @apply flex flex-col gap-2;
+}
+
+.new-thread-open-folder-create-composer {
+ @apply flex items-center gap-2;
+}
+
+.new-thread-open-folder-create-input {
+ @apply w-full min-w-0 flex-1 rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 outline-none transition focus:border-zinc-400;
+}
+
+.new-thread-open-folder-create-submit {
+ @apply shrink-0;
+}
+
+.new-thread-folder-action[aria-pressed='true'] {
+ @apply border-zinc-900 bg-zinc-900 text-white hover:bg-zinc-800;
+}
+
+.new-thread-open-folder-status {
+ @apply m-0 rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600;
+}
+
+.new-thread-open-folder-error {
+ @apply m-0 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700;
+}
+
+.new-thread-open-folder-error-actions {
+ @apply flex flex-wrap items-start gap-2;
+}
+
+.new-thread-open-folder-list {
+ @apply m-0 flex max-h-72 list-none flex-col gap-1 overflow-y-auto p-0 pr-3;
+ scrollbar-gutter: stable;
+ scrollbar-color: rgb(161 161 170) rgb(244 244 245);
+ scrollbar-width: thin;
+}
+
+.new-thread-open-folder-list::-webkit-scrollbar {
+ width: 10px;
+}
+
+.new-thread-open-folder-list::-webkit-scrollbar-track {
+ background: rgb(244 244 245);
+ border-radius: 9999px;
+}
+
+.new-thread-open-folder-list::-webkit-scrollbar-thumb {
+ background: rgb(161 161 170);
+ border-radius: 9999px;
+ border: 2px solid rgb(244 244 245);
+}
+
+.new-thread-open-folder-list::-webkit-scrollbar-thumb:hover {
+ background: rgb(113 113 122);
+}
+
+.new-thread-open-folder-item {
+ @apply grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1;
+}
+
+.new-thread-open-folder-item-main {
+ @apply min-w-0 truncate rounded-xl border border-zinc-200 bg-zinc-50 px-2.5 py-1 text-left text-sm font-medium leading-5 text-zinc-900 transition hover:border-zinc-300 hover:bg-zinc-100;
+}
+
+.new-thread-open-folder-item-main:disabled,
+.new-thread-open-folder-item-open:disabled {
+ @apply cursor-default opacity-60;
+}
+
+.new-thread-open-folder-item-name {
+ @apply block truncate;
+}
+
+.new-thread-open-folder-item-open {
+ @apply inline-flex h-7 items-center justify-center rounded-xl border border-zinc-200 bg-white px-2.5 text-xs font-medium text-zinc-700 transition hover:bg-zinc-50;
+}
+
+.new-thread-runtime-dropdown {
+ @apply mt-3;
+}
+
+.new-thread-branch-select {
+ @apply mt-3 w-full max-w-3xl;
+}
+
+.new-thread-branch-select-label {
+ @apply m-0 mb-1 text-xs font-medium uppercase tracking-wide text-zinc-500;
+}
+
+.new-thread-branch-dropdown :deep(.composer-dropdown-trigger) {
+ @apply h-9 rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700;
+}
+
+.new-thread-branch-select-help {
+ @apply mt-1 mb-0 text-xs text-zinc-500;
+}
+
+.new-thread-runtime-help {
+ @apply mt-2 mb-0 max-w-3xl text-center text-xs text-zinc-500;
+}
+
+.worktree-init-status {
+ @apply mt-3 flex w-full max-w-xl flex-col gap-1 rounded-xl border px-3 py-2 text-sm;
+}
+
+.worktree-init-status.is-running {
+ @apply border-zinc-300 bg-zinc-50 text-zinc-700;
+}
+
+.worktree-init-status.is-error {
+ @apply border-rose-300 bg-rose-50 text-rose-800;
+}
+
+.worktree-init-status-title {
+ @apply font-medium;
+}
+
+.worktree-init-status-message {
+ @apply break-all;
+}
+
+.sidebar-settings-area {
+ @apply shrink-0 bg-slate-100 pt-2 px-2 pb-2 border-t border-zinc-200;
+}
+
+.sidebar-settings-button {
+ @apply flex items-center gap-2 w-full rounded-lg border-0 bg-transparent px-2 py-2 text-sm text-zinc-600 transition hover:bg-zinc-200 hover:text-zinc-900 cursor-pointer;
+}
+
+.sidebar-settings-button-version {
+ @apply ml-auto min-w-0 truncate text-right text-xs;
+}
+
+.sidebar-settings-icon {
+ @apply w-4.5 h-4.5;
+}
+
+.sidebar-settings-panel {
+ @apply mb-1 max-h-[min(70vh,36rem)] overflow-y-auto rounded-lg border border-zinc-200 bg-white;
+}
+
+.sidebar-settings-row {
+ @apply flex items-center justify-between w-full px-3 py-2.5 text-sm text-zinc-700 border-0 bg-transparent transition hover:bg-zinc-50 cursor-pointer;
+}
+
+.sidebar-settings-row--select {
+ @apply cursor-default items-center gap-2;
+}
+
+.sidebar-settings-language-dropdown {
+ @apply min-w-0 max-w-52;
+}
+
+.sidebar-settings-language-dropdown :deep(.composer-dropdown-trigger) {
+ @apply h-auto rounded-md border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700;
+}
+
+.sidebar-settings-language-dropdown :deep(.composer-dropdown-value) {
+ @apply max-w-32;
+}
+
+.sidebar-settings-row + .sidebar-settings-row {
+ @apply border-t border-zinc-100;
+}
+
+.sidebar-settings-telegram-panel {
+ @apply border-t border-zinc-100 bg-zinc-50/70 px-3 py-3;
+}
+
+.sidebar-settings-field {
+ @apply flex flex-col gap-1.5;
+}
+
+.sidebar-settings-field + .sidebar-settings-field {
+ @apply mt-3;
+}
+
+.sidebar-settings-field-label {
+ @apply text-xs font-medium text-zinc-700;
+}
+
+.sidebar-settings-input,
+.sidebar-settings-textarea {
+ @apply w-full rounded-md border border-zinc-200 bg-white px-2.5 py-2 text-sm text-zinc-800 outline-none transition focus:border-zinc-400 focus:ring-2 focus:ring-zinc-200;
+}
+
+.sidebar-settings-textarea {
+ @apply min-h-20 resize-y font-mono text-xs;
+}
+
+.sidebar-settings-field-help {
+ @apply mt-2 text-xs leading-5 text-zinc-500;
+}
+
+.sidebar-settings-telegram-error {
+ @apply mt-2 rounded-md bg-rose-50 px-2.5 py-2 text-xs text-rose-700;
+}
+
+.sidebar-settings-telegram-actions {
+ @apply mt-3 flex items-center justify-end;
+}
+
+.sidebar-settings-telegram-save {
+ @apply rounded-full border border-zinc-200 bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 transition hover:bg-zinc-50 disabled:cursor-default disabled:opacity-60;
+}
+
+.sidebar-settings-account-section {
+ @apply border-t border-zinc-100 bg-zinc-50/60 px-3 py-3;
+}
+
+.sidebar-settings-account-header {
+ @apply mb-2 flex items-center justify-between gap-2;
+}
+
+.sidebar-settings-account-header-main {
+ @apply flex items-center gap-2;
+}
+
+.sidebar-settings-account-collapse {
+ @apply inline-flex h-5 w-5 items-center justify-center rounded border border-zinc-200 bg-white text-zinc-600 transition hover:bg-zinc-100;
+}
+
+.sidebar-settings-account-collapse-icon {
+ @apply text-[11px] leading-none;
+}
+
+.sidebar-settings-account-title {
+ @apply text-sm font-medium text-zinc-800;
+}
+
+.sidebar-settings-account-count {
+ @apply rounded bg-zinc-200 px-1.5 py-0.5 text-[11px] text-zinc-600;
+}
+
+.sidebar-settings-account-error {
+ @apply mb-2 rounded-md bg-rose-50 px-2 py-1.5 text-xs text-rose-700;
+}
+
+.sidebar-settings-account-refresh {
+ @apply shrink-0 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-xs text-zinc-700 transition hover:bg-zinc-50 disabled:cursor-default disabled:opacity-60;
+}
+
+.sidebar-settings-account-login {
+ @apply mb-2 flex items-center gap-2;
+}
+
+.sidebar-settings-account-login-button {
+ @apply shrink-0 rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-medium text-zinc-700 transition hover:bg-zinc-50 disabled:cursor-default disabled:opacity-60;
+}
+
+.sidebar-settings-account-login-link {
+ @apply min-w-0 truncate text-xs text-blue-600 hover:text-blue-700 hover:underline;
+}
+
+.sidebar-settings-account-empty {
+ @apply text-xs text-zinc-500;
+}
+
+.codex-login-modal-backdrop {
+ @apply fixed inset-0 z-[100] flex items-center justify-center bg-black/35 px-4;
+}
+
+.codex-login-modal {
+ @apply flex w-full max-w-md flex-col gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-2xl;
+}
+
+.codex-login-modal-header {
+ @apply flex items-center justify-between gap-3;
+}
+
+.codex-login-modal-title {
+ @apply text-base font-semibold text-zinc-900;
+}
+
+.codex-login-modal-close {
+ @apply inline-flex h-7 w-7 items-center justify-center rounded-full border border-zinc-200 bg-white text-lg leading-none text-zinc-600 transition hover:bg-zinc-50 disabled:cursor-default disabled:opacity-60;
+}
+
+.codex-login-modal-copy {
+ @apply text-sm leading-5 text-zinc-600;
+}
+
+.codex-login-modal-link {
+ @apply min-w-0 truncate text-sm text-blue-600 hover:text-blue-700 hover:underline;
+}
+
+.codex-login-modal-input {
+ @apply w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 outline-none transition focus:border-zinc-400 disabled:cursor-default disabled:opacity-60;
+}
+
+.codex-login-modal-error {
+ @apply rounded-md bg-rose-50 px-3 py-2 text-xs text-rose-700;
+}
+
+.codex-login-modal-actions {
+ @apply flex items-center justify-end gap-2;
+}
+
+.codex-login-modal-cancel,
+.codex-login-modal-submit {
+ @apply rounded-full border border-zinc-200 bg-white px-3 py-1.5 text-sm text-zinc-700 transition hover:bg-zinc-50 disabled:cursor-default disabled:opacity-60;
+}
+
+.codex-login-modal-submit {
+ @apply border-zinc-900 bg-zinc-900 text-white hover:bg-zinc-800;
+}
+
+:global(:root.dark) .codex-login-modal {
+ @apply border-zinc-700 bg-zinc-900;
+}
+
+:global(:root.dark) .codex-login-modal-title {
+ @apply text-zinc-100;
+}
+
+:global(:root.dark) .codex-login-modal-close,
+:global(:root.dark) .codex-login-modal-cancel {
+ @apply border-zinc-600 bg-zinc-800 text-zinc-200 hover:bg-zinc-700;
+}
+
+:global(:root.dark) .codex-login-modal-copy {
+ @apply text-zinc-300;
+}
+
+:global(:root.dark) .codex-login-modal-link {
+ @apply text-sky-300 hover:text-sky-200;
+}
+
+:global(:root.dark) .codex-login-modal-input {
+ @apply border-zinc-600 bg-zinc-950 text-zinc-100 placeholder:text-zinc-500 focus:border-zinc-400;
+}
+
+:global(:root.dark) .codex-login-modal-error {
+ @apply bg-rose-950/40 text-rose-200;
+}
+
+:global(:root.dark) .codex-login-modal-submit {
+ @apply border-zinc-200 bg-zinc-100 text-zinc-900 hover:bg-white;
+}
+
+.sidebar-settings-account-list {
+ @apply flex flex-col gap-2;
+}
+
+.sidebar-settings-account-item {
+ @apply flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-2.5 py-2;
+}
+
+.sidebar-settings-account-item.is-active {
+ @apply border-emerald-200 bg-emerald-50;
+}
+
+.sidebar-settings-account-item.is-unavailable {
+ @apply border-rose-200 bg-rose-50;
+}
+
+.sidebar-settings-account-main {
+ @apply min-w-0 flex-1;
+}
+
+.sidebar-settings-account-actions {
+ @apply flex w-24 shrink-0 flex-col items-end gap-1.5;
+}
+
+.sidebar-settings-account-email {
+ @apply truncate text-sm text-zinc-800;
+}
+
+.sidebar-settings-account-meta {
+ @apply truncate text-[11px] text-zinc-500;
+}
+
+.sidebar-settings-account-quota {
+ @apply truncate text-[11px] text-zinc-600;
+}
+
+.sidebar-settings-account-id {
+ @apply mt-1 inline-flex max-w-full rounded-full bg-zinc-100 px-2 py-0.5 font-mono text-[11px] text-zinc-700;
+}
+
+.sidebar-settings-account-item.is-active .sidebar-settings-account-id {
+ @apply bg-emerald-100 text-emerald-800;
+}
+
+.sidebar-settings-account-item.is-unavailable .sidebar-settings-account-id {
+ @apply bg-rose-100 text-rose-800;
+}
+
+.sidebar-settings-account-switch {
+ @apply min-w-[4.75rem] shrink-0 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-center text-xs text-zinc-700 transition hover:bg-zinc-50 disabled:cursor-default disabled:opacity-60;
+}
+
+.sidebar-settings-account-remove {
+ @apply invisible shrink-0 rounded-full border border-amber-200 bg-white px-2 py-0.5 text-[10px] leading-4 text-zinc-500 opacity-0 pointer-events-none transition-colors hover:bg-amber-50 disabled:cursor-default disabled:opacity-60;
+}
+
+.sidebar-settings-account-remove.is-visible {
+ @apply visible opacity-100 pointer-events-auto;
+}
+
+.sidebar-settings-account-remove.is-confirming {
+ @apply border-amber-300 bg-amber-50 text-amber-700 font-medium;
+}
+
+.sidebar-settings-label {
+ @apply text-left;
+}
+
+.sidebar-settings-value {
+ @apply text-xs text-zinc-500 bg-zinc-100 rounded px-1.5 py-0.5;
+}
+
+
+.sidebar-settings-toggle {
+ @apply relative w-9 h-5 rounded-full bg-zinc-300 transition-colors shrink-0;
+}
+
+.sidebar-settings-toggle::after {
+ content: '';
+ @apply absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform shadow-sm;
+}
+
+.sidebar-settings-toggle.is-on {
+ @apply bg-zinc-800;
+}
+
+.sidebar-settings-toggle.is-on::after {
+ transform: translateX(16px);
+}
+
+.sidebar-settings-row--input {
+ @apply flex flex-col gap-1 py-1.5;
+}
+
+.sidebar-settings-error {
+ @apply text-xs text-red-600 bg-red-50 rounded px-2 py-1.5 break-words;
+}
+
+.sidebar-settings-key-group {
+ @apply flex items-center gap-1.5 w-full;
+}
+
+.sidebar-settings-key-input {
+ @apply flex-1 min-w-0 text-xs rounded border border-zinc-200 bg-white px-2 py-1 outline-none transition-colors placeholder:text-zinc-400;
+}
+
+.sidebar-settings-key-input:focus {
+ @apply border-zinc-400;
+}
+
+.sidebar-settings-key-save {
+ @apply shrink-0 rounded border border-zinc-200 bg-white px-2.5 py-1 text-xs text-zinc-700 transition-colors hover:bg-zinc-50 disabled:opacity-40 disabled:cursor-default;
+}
+
+.sidebar-settings-key-masked {
+ @apply flex-1 min-w-0 text-xs text-zinc-500 font-mono truncate;
+}
+
+.sidebar-settings-key-clear {
+ @apply shrink-0 w-6 h-6 flex items-center justify-center rounded-full border border-zinc-200 text-xs text-zinc-400 transition-colors hover:text-zinc-600 hover:border-zinc-300 disabled:opacity-40;
+}
+
+.sidebar-settings-provider-select {
+ @apply min-w-0 max-w-40 rounded-md border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 outline-none transition-colors cursor-pointer;
+}
+
+.sidebar-settings-provider-select:focus {
+ @apply border-zinc-400 ring-2 ring-zinc-200;
+}
+
+.sidebar-settings-segmented {
+ @apply inline-flex items-center rounded-md border border-zinc-200 bg-white p-0.5;
+}
+
+.sidebar-settings-segmented-option {
+ @apply rounded px-2 py-1 text-xs text-zinc-600 transition-colors;
+}
+
+.sidebar-settings-segmented-option.is-active {
+ @apply bg-zinc-800 text-white;
+}
+
+.sidebar-settings-provider-info {
+ @apply flex items-center justify-between w-full;
+}
+
+.sidebar-settings-provider-link {
+ @apply text-xs text-blue-600 hover:text-blue-700 underline shrink-0;
+}
+
+:root.dark .sidebar-settings-provider-select {
+ @apply border-zinc-600 bg-zinc-800 text-zinc-200;
+}
+
+:root.dark .sidebar-settings-provider-select:focus {
+ @apply border-zinc-500 ring-zinc-700;
+}
+
+:root.dark .sidebar-settings-segmented {
+ @apply border-zinc-600 bg-zinc-800;
+}
+
+:root.dark .sidebar-settings-segmented-option {
+ @apply text-zinc-300;
+}
+
+:root.dark .sidebar-settings-segmented-option.is-active {
+ @apply bg-zinc-100 text-zinc-900;
+}
+
+:root.dark .sidebar-settings-provider-link {
+ @apply text-blue-400 hover:text-blue-300;
+}
+
+:root.dark .sidebar-settings-key-input {
+ @apply border-zinc-600 bg-zinc-800 text-zinc-200 placeholder:text-zinc-500;
+}
+
+:root.dark .sidebar-settings-key-input:focus {
+ @apply border-zinc-500;
+}
+
+:root.dark .sidebar-settings-key-save {
+ @apply border-zinc-600 bg-zinc-700 text-zinc-200 hover:bg-zinc-600;
+}
+
+:root.dark .sidebar-settings-key-masked {
+ @apply text-zinc-400;
+}
+
+:root.dark .sidebar-settings-key-clear {
+ @apply border-zinc-600 text-zinc-500 hover:text-zinc-300 hover:border-zinc-500;
+}
+
+.settings-panel-enter-active,
+.settings-panel-leave-active {
+ transition: all 150ms ease;
+}
+
+.settings-panel-enter-from,
+.settings-panel-leave-to {
+ opacity: 0;
+ transform: translateY(8px);
+}
+
+.sidebar-settings-context-row {
+ @apply cursor-default;
+}
+
+.sidebar-settings-context-value {
+ @apply text-xs font-semibold text-zinc-700 text-right;
+}
+
+.sidebar-settings-context-value[data-state='ok'] {
+ @apply text-emerald-700;
+}
+
+.sidebar-settings-context-value[data-state='warning'] {
+ @apply text-amber-700;
+}
+
+.sidebar-settings-context-value[data-state='danger'] {
+ @apply text-rose-700;
+}
+
+.sidebar-settings-context-meta {
+ @apply block text-[11px] font-normal text-zinc-500;
+}
+
+.sidebar-settings-rate-limits {
+ @apply border-t border-zinc-200 px-2 pt-2;
+}
+
+.sidebar-settings-build-label {
+ @apply border-t border-zinc-100 px-3 py-2 text-[11px] text-zinc-500;
+}
diff --git a/src/App.template.html b/src/App.template.html
new file mode 100644
index 000000000..62c77c86d
--- /dev/null
+++ b/src/App.template.html
@@ -0,0 +1,1065 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t("Let's build") }}
+
+
+ {{ t('Selected folder') }}: {{ newThreadCwd }}
+
+
+
+ {{ t('Select folder') }}
+
+
+ {{ t('Create Project') }}
+
+
+
+
+
+
+
+
+
{{ t('New in Codex') }}
+
+
{{ t('Plugins are here') }}
+
+ {{ t('Hook Codex up to Gmail, Calendar, GitHub, Slack, Browser Use, and more so it can actually help with real work right away.') }}
+
+
+ Gmail
+ Calendar
+ GitHub
+ Slack
+ Browser Use
+
+
+
+
+ {{ t('Explore Plugins & Apps') }}
+
+
+ {{ t('Dismiss') }}
+
+
+
+
+
+
+
+
{{ t('Current folder') }}
+
+
+
+ {{ isOpeningExistingFolder ? t('Opening…') : t('Open') }}
+
+
+
+
+
+ {{ t('Show hidden folders') }}
+
+
+ {{ t('New folder') }}
+
+
+
+
+
+
+ {{ createFolderSubmitLabel }}
+
+
+
{{ createFolderError }}
+
+
+
+
{{ existingFolderError }}
+
+ {{ t('Retry') }}
+
+
+
{{ t('Loading folders…') }}
+
+ {{ existingFolderFilter.trim() ? t('No folders match this filter.') : t('No subfolders found here.') }}
+
+
+
+
+ {{ entry.name }}
+
+
+ {{ t('Open') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('New project') }}
+
+
+ {{ t('Clone from GitHub') }}
+
+
+
+ {{ t('Destination folder') }}
+
+
+
+ {{ t('Project name') }}
+
+
+
+ {{ t('GitHub repository URL') }}
+
+
+
{{ projectSetupError }}
+
+
+ {{ t('Cancel') }}
+
+
+ {{ projectSetupSubmitLabel }}
+
+
+
+
+
+
+
+
{{ t('Base branch') }}
+
+
+ {{
+ isLoadingWorktreeBranches
+ ? t('Loading branches…')
+ : selectedWorktreeBranchLabel
+ ? t('New worktree branch will start from {branch}.', { branch: selectedWorktreeBranchLabel })
+ : t('No Git branches found for this folder.')
+ }}
+
+
+
+ {{ t('Local project uses the selected folder directly. New worktree creates an isolated Git worktree before the first prompt.') }}
+
+
+ {{ worktreeInitStatus.title }}
+ {{ worktreeInitStatus.message }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.vue b/src/App.vue
index 77a0b2638..f5063b4fe 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,1070 +1,4 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ t("Let's build") }}
-
-
- {{ t('Selected folder') }}: {{ newThreadCwd }}
-
-
-
- {{ t('Select folder') }}
-
-
- {{ t('Create Project') }}
-
-
-
-
-
-
-
-
-
{{ t('New in Codex') }}
-
-
{{ t('Plugins are here') }}
-
- {{ t('Hook Codex up to Gmail, Calendar, GitHub, Slack, Browser Use, and more so it can actually help with real work right away.') }}
-
-
- Gmail
- Calendar
- GitHub
- Slack
- Browser Use
-
-
-
-
- {{ t('Explore Plugins & Apps') }}
-
-
- {{ t('Dismiss') }}
-
-
-
-
-
-
-
-
{{ t('Current folder') }}
-
-
-
- {{ isOpeningExistingFolder ? t('Opening…') : t('Open') }}
-
-
-
-
-
- {{ t('Show hidden folders') }}
-
-
- {{ t('New folder') }}
-
-
-
-
-
-
- {{ createFolderSubmitLabel }}
-
-
-
{{ createFolderError }}
-
-
-
-
{{ existingFolderError }}
-
- {{ t('Retry') }}
-
-
-
{{ t('Loading folders…') }}
-
- {{ existingFolderFilter.trim() ? t('No folders match this filter.') : t('No subfolders found here.') }}
-
-
-
-
- {{ entry.name }}
-
-
- {{ t('Open') }}
-
-
-
-
-
-
-
-
-
-
-
-
- {{ t('New project') }}
-
-
- {{ t('Clone from GitHub') }}
-
-
-
- {{ t('Destination folder') }}
-
-
-
- {{ t('Project name') }}
-
-
-
- {{ t('GitHub repository URL') }}
-
-
-
{{ projectSetupError }}
-
-
- {{ t('Cancel') }}
-
-
- {{ projectSetupSubmitLabel }}
-
-
-
-
-
-
-
-
{{ t('Base branch') }}
-
-
- {{
- isLoadingWorktreeBranches
- ? t('Loading branches…')
- : selectedWorktreeBranchLabel
- ? t('New worktree branch will start from {branch}.', { branch: selectedWorktreeBranchLabel })
- : t('No Git branches found for this folder.')
- }}
-
-
-
- {{ t('Local project uses the selected folder directly. New worktree creates an isolated Git worktree before the first prompt.') }}
-
-
- {{ worktreeInitStatus.title }}
- {{ worktreeInitStatus.message }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts
index 25a762d10..76c697c14 100644
--- a/src/api/codexGateway.ts
+++ b/src/api/codexGateway.ts
@@ -42,10 +42,6 @@ import type {
UiThread,
UiReviewAction,
UiReviewActionLevel,
- UiReviewFile,
- UiReviewFinding,
- UiReviewHunk,
- UiReviewLine,
UiReviewResult,
UiReviewScope,
UiReviewSnapshot,
@@ -56,6 +52,9 @@ import type {
UiThreadAutomationStatus,
} from '../types/codex'
import { normalizePathForUi } from '../pathUtils.js'
+import { normalizeReviewSnapshot, parseReviewText, readLatestReviewItem } from './codexGatewayReview'
+import { normalizeDirectoryApp, normalizeDirectoryMcpServer, normalizeDirectoryPluginApp, normalizeDirectoryPluginSkill, normalizeDirectoryPluginSummary } from './codexGatewayDirectory'
+export { attachThreadTerminal, closeThreadTerminal, getThreadTerminalQuickCommands, getThreadTerminalSnapshot, getThreadTerminalStatus, resizeThreadTerminal, sendThreadTerminalInput } from './codexGatewayTerminal'
type CurrentModelConfig = {
model: string
@@ -854,205 +853,6 @@ export async function getOlderThreadMessages(threadId: string, beforeTurnId: str
}
}
-function normalizeReviewLine(value: unknown): UiReviewLine | null {
- const record = asRecord(value)
- if (!record) return null
-
- const key = readString(record.key)
- const text = typeof record.text === 'string' ? record.text : ''
- const kind = readString(record.kind)
- if (!key || !kind) return null
- if (kind !== 'meta' && kind !== 'hunk' && kind !== 'add' && kind !== 'remove' && kind !== 'context') {
- return null
- }
-
- return {
- key,
- kind,
- text,
- oldLine: readNumber(record.oldLine),
- newLine: readNumber(record.newLine),
- }
-}
-
-function normalizeReviewHunk(value: unknown): UiReviewHunk | null {
- const record = asRecord(value)
- if (!record) return null
-
- const id = readString(record.id)
- const header = typeof record.header === 'string' ? record.header : ''
- const patch = typeof record.patch === 'string' ? record.patch : ''
- if (!id) return null
-
- return {
- id,
- header,
- patch,
- addedLineCount: readNumber(record.addedLineCount) ?? 0,
- removedLineCount: readNumber(record.removedLineCount) ?? 0,
- oldStart: readNumber(record.oldStart),
- oldLineCount: readNumber(record.oldLineCount) ?? 0,
- newStart: readNumber(record.newStart),
- newLineCount: readNumber(record.newLineCount) ?? 0,
- lines: Array.isArray(record.lines)
- ? record.lines
- .map((entry) => normalizeReviewLine(entry))
- .filter((entry): entry is UiReviewLine => entry !== null)
- : [],
- }
-}
-
-function normalizeReviewFile(value: unknown): UiReviewFile | null {
- const record = asRecord(value)
- if (!record) return null
-
- const id = readString(record.id)
- const path = readString(record.path)
- const absolutePath = readString(record.absolutePath)
- const operation = readString(record.operation)
- if (!id || !path || !absolutePath || !operation) return null
- if (operation !== 'add' && operation !== 'delete' && operation !== 'update' && operation !== 'rename') {
- return null
- }
-
- return {
- id,
- path,
- absolutePath,
- previousPath: readString(record.previousPath),
- previousAbsolutePath: readString(record.previousAbsolutePath),
- operation,
- addedLineCount: readNumber(record.addedLineCount) ?? 0,
- removedLineCount: readNumber(record.removedLineCount) ?? 0,
- diff: typeof record.diff === 'string' ? record.diff : '',
- hunks: Array.isArray(record.hunks)
- ? record.hunks
- .map((entry) => normalizeReviewHunk(entry))
- .filter((entry): entry is UiReviewHunk => entry !== null)
- : [],
- }
-}
-
-function normalizeReviewSnapshot(payload: unknown): UiReviewSnapshot {
- const envelope = asRecord(payload)
- const data = asRecord(envelope?.data)
- const summaryRecord = asRecord(data?.summary)
- const scope = readString(data?.scope) === 'baseBranch' ? 'baseBranch' : 'workspace'
- const workspaceView = readString(data?.workspaceView) === 'staged' ? 'staged' : 'unstaged'
-
- return {
- cwd: readString(data?.cwd) ?? '',
- gitRoot: readString(data?.gitRoot),
- isGitRepo: readBoolean(data?.isGitRepo) ?? false,
- scope,
- workspaceView,
- baseBranch: readString(data?.baseBranch),
- baseBranchOptions: Array.isArray(data?.baseBranchOptions)
- ? data.baseBranchOptions
- .map((entry) => readString(entry))
- .filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
- : [],
- headBranch: readString(data?.headBranch),
- mergeBaseSha: readString(data?.mergeBaseSha),
- generatedAtIso: readString(data?.generatedAtIso) ?? '',
- summary: {
- fileCount: readNumber(summaryRecord?.fileCount) ?? 0,
- addedLineCount: readNumber(summaryRecord?.addedLineCount) ?? 0,
- removedLineCount: readNumber(summaryRecord?.removedLineCount) ?? 0,
- },
- files: Array.isArray(data?.files)
- ? data.files
- .map((entry) => normalizeReviewFile(entry))
- .filter((entry): entry is UiReviewFile => entry !== null)
- : [],
- }
-}
-
-function parseReviewLocation(value: string): {
- absolutePath: string | null
- startLine: number | null
- endLine: number | null
-} {
- const trimmed = value.trim()
- if (!trimmed) {
- return { absolutePath: null, startLine: null, endLine: null }
- }
-
- const match = trimmed.match(/^(.*?):(\d+)-(\d+)$/u)
- if (!match) {
- return { absolutePath: trimmed || null, startLine: null, endLine: null }
- }
-
- return {
- absolutePath: match[1]?.trim() || null,
- startLine: Number(match[2]),
- endLine: Number(match[3]),
- }
-}
-
-function parseReviewText(reviewText: string): UiReviewResult {
- const normalized = reviewText.replace(/\r\n/g, '\n').trim()
- if (!normalized) {
- return { reviewText: '', summary: '', findings: [] }
- }
-
- const markerIndex = normalized.search(/\n(?:Full review comments|Review comment):\n/iu)
- const summary = markerIndex >= 0 ? normalized.slice(0, markerIndex).trim() : normalized
- const findingsSection = markerIndex >= 0 ? normalized.slice(markerIndex).trim() : ''
- const findings: UiReviewFinding[] = []
-
- if (findingsSection) {
- const body = findingsSection
- .replace(/^(?:Full review comments|Review comment):\n*/iu, '')
- .trim()
- const matches = body.matchAll(/^- (.+?) — (.+)\n?((?: .*(?:\n|$))*)/gmu)
- let index = 0
- for (const match of matches) {
- const title = match[1]?.trim() ?? ''
- const location = parseReviewLocation(match[2] ?? '')
- const block = (match[0] ?? '').trim()
- const findingBody = (match[3] ?? '')
- .split('\n')
- .map((line) => line.replace(/^ /u, ''))
- .join('\n')
- .trim()
-
- findings.push({
- id: `finding:${index}`,
- title: title || `Finding ${index + 1}`,
- body: findingBody,
- path: location.absolutePath ? location.absolutePath.split('/').filter(Boolean).slice(-1)[0] ?? location.absolutePath : null,
- absolutePath: location.absolutePath,
- startLine: location.startLine,
- endLine: location.endLine,
- rawText: block,
- })
- index += 1
- }
- }
-
- return {
- reviewText: normalized,
- summary,
- findings,
- }
-}
-
-function readLatestReviewItem(payload: ThreadReadResponse, type: 'enteredReviewMode' | 'exitedReviewMode'): string | null {
- const turns = Array.isArray(payload.thread.turns) ? payload.thread.turns : []
- for (let turnIndex = turns.length - 1; turnIndex >= 0; turnIndex -= 1) {
- const turn = turns[turnIndex]
- const items = Array.isArray(turn?.items) ? turn.items : []
- for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) {
- const item = items[itemIndex]
- if (item?.type !== type) continue
- const review = typeof item.review === 'string' ? item.review.trim() : ''
- if (review) return review
- }
- }
- return null
-}
-
export async function getThreadReviewResult(threadId: string): Promise<{
enteredReviewLabel: string | null
result: UiReviewResult | null
@@ -1244,96 +1044,6 @@ export function subscribeCodexNotifications(onNotification: (value: RpcNotificat
export type { RpcNotification }
-function normalizeThreadTerminalSession(value: unknown): ThreadTerminalSession | null {
- const record = asRecord(value)
- if (!record) return null
- const id = readString(record.id)
- const threadId = readString(record.threadId)
- const cwd = readString(record.cwd)
- const shell = readString(record.shell)
- if (!id || !threadId || !cwd || !shell) return null
- return {
- id,
- threadId,
- cwd,
- shell,
- buffer: typeof record.buffer === 'string' ? record.buffer : '',
- truncated: readBoolean(record.truncated) ?? false,
- }
-}
-
-async function fetchTerminalJson(path: string, init?: RequestInit): Promise {
- const response = await fetch(path, init)
- const payload = await response.json().catch(() => null)
- if (!response.ok) {
- throw new Error(extractErrorMessage(payload, `Terminal request failed with HTTP ${response.status}`))
- }
- return payload
-}
-
-export async function attachThreadTerminal(input: ThreadTerminalAttachInput): Promise {
- const payload = await fetchTerminalJson('/codex-api/thread-terminal/attach', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(input),
- })
- const session = normalizeThreadTerminalSession(asRecord(payload)?.session)
- if (!session) throw new Error('Terminal attach response was malformed')
- return session
-}
-
-export async function getThreadTerminalStatus(): Promise<{ available: boolean, reason: string | null }> {
- const payload = await fetchTerminalJson('/codex-api/thread-terminal/status')
- const record = asRecord(payload)
- return {
- available: readBoolean(record?.available) ?? false,
- reason: readString(record?.reason) || null,
- }
-}
-
-export async function getThreadTerminalQuickCommands(cwd: string): Promise {
- const payload = await fetchTerminalJson(`/codex-api/thread-terminal/quick-commands?cwd=${encodeURIComponent(cwd)}`)
- const payloadRecord = asRecord(payload)
- const rows: unknown[] = Array.isArray(payloadRecord?.commands) ? payloadRecord.commands : []
- return rows.flatMap((row: unknown) => {
- const record = asRecord(row)
- const label = readString(record?.label)
- const value = readString(record?.value)
- const source = readString(record?.source)
- if (!label || !value || (source !== 'package' && source !== 'script' && source !== 'make')) return []
- return [{ label, value, source }]
- })
-}
-
-export async function sendThreadTerminalInput(sessionId: string, data: string): Promise {
- await fetchTerminalJson('/codex-api/thread-terminal/input', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ sessionId, data }),
- })
-}
-
-export async function resizeThreadTerminal(sessionId: string, cols: number, rows: number): Promise {
- await fetchTerminalJson('/codex-api/thread-terminal/resize', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ sessionId, cols, rows }),
- })
-}
-
-export async function closeThreadTerminal(sessionId: string): Promise {
- await fetchTerminalJson('/codex-api/thread-terminal/close', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ sessionId }),
- })
-}
-
-export async function getThreadTerminalSnapshot(threadId: string): Promise {
- const payload = await fetchTerminalJson(`/codex-api/thread-terminal-snapshot?threadId=${encodeURIComponent(threadId)}`)
- return normalizeThreadTerminalSession(asRecord(payload)?.session)
-}
-
export async function replyToServerRequest(
id: number,
payload: { result?: unknown; error?: { code?: number; message: string } },
@@ -1937,166 +1647,6 @@ export async function getCurrentModelConfig(): Promise {
return { model, providerId, reasoningEffort, speedMode }
}
-function normalizeDirectoryPluginApp(value: unknown): DirectoryPluginAppSummary | null {
- const record = asRecord(value)
- if (!record) return null
- const id = readString(record.id)
- const name = readString(record.name)
- if (!id || !name) return null
- return {
- id,
- name,
- description: readString(record.description) ?? '',
- installUrl: readString(record.installUrl ?? record.install_url) ?? '',
- needsAuth: readBoolean(record.needsAuth ?? record.needs_auth) ?? false,
- }
-}
-
-function normalizeDirectoryPluginSkill(value: unknown): DirectoryPluginSkillSummary | null {
- const record = asRecord(value)
- if (!record) return null
- const name = readString(record.name)
- const path = readString(record.path)
- if (!name || !path) return null
- const iface = asRecord(record.interface)
- return {
- name,
- path,
- description: readString(record.description) ?? '',
- enabled: readBoolean(record.enabled) ?? true,
- displayName: readString(iface?.displayName ?? iface?.display_name) ?? name,
- shortDescription: readString(record.shortDescription ?? record.short_description) ?? '',
- }
-}
-
-function normalizeDirectoryPluginSummary(
- value: unknown,
- marketplace: { name?: string; displayName?: string; path?: string | null } = {},
-): DirectoryPluginSummary | null {
- const record = asRecord(value)
- if (!record) return null
- const id = readString(record.id)
- const name = readString(record.name)
- if (!id || !name) return null
- const iface = asRecord(record.interface)
- const source = asRecord(record.source)
- const sourceType = readString(source?.type) ?? ''
- const sourcePath = readString(source?.path)
- const sourceUrl = readString(source?.url) ?? ''
- const remoteMarketplaceName = sourceType === 'remote' ? marketplace.name ?? null : null
- const marketplacePath = marketplace.path ?? (sourceType === 'local' ? sourcePath : null)
- const displayName = readString(iface?.displayName ?? iface?.display_name) ?? name
- const shortDescription = readString(iface?.shortDescription ?? iface?.short_description)
- const longDescription = readString(iface?.longDescription ?? iface?.long_description) ?? ''
-
- return {
- id,
- name,
- displayName,
- description: shortDescription ?? longDescription,
- longDescription,
- developerName: readString(iface?.developerName ?? iface?.developer_name) ?? '',
- category: readString(iface?.category) ?? '',
- marketplaceName: marketplace.name ?? '',
- marketplaceDisplayName: marketplace.displayName ?? marketplace.name ?? '',
- marketplacePath,
- remoteMarketplaceName,
- sourceType,
- sourceUrl,
- installed: readBoolean(record.installed) ?? false,
- enabled: readBoolean(record.enabled) ?? true,
- installPolicy: readString(record.installPolicy ?? record.install_policy) ?? '',
- authPolicy: readString(record.authPolicy ?? record.auth_policy) ?? '',
- logoUrl: readString(iface?.logoUrl ?? iface?.logo_url) ?? '',
- logoPath: readString(iface?.logo) ?? '',
- composerIconUrl: readString(iface?.composerIconUrl ?? iface?.composer_icon_url) ?? '',
- composerIconPath: readString(iface?.composerIcon ?? iface?.composer_icon) ?? '',
- brandColor: readString(iface?.brandColor ?? iface?.brand_color) ?? '',
- capabilities: readStringArray(iface?.capabilities),
- defaultPrompt: readStringArray(iface?.defaultPrompt ?? iface?.default_prompt),
- screenshotUrls: readStringArray(iface?.screenshotUrls ?? iface?.screenshot_urls),
- screenshots: readStringArray(iface?.screenshots),
- websiteUrl: readString(iface?.websiteUrl ?? iface?.website_url) ?? '',
- privacyPolicyUrl: readString(iface?.privacyPolicyUrl ?? iface?.privacy_policy_url) ?? '',
- termsOfServiceUrl: readString(iface?.termsOfServiceUrl ?? iface?.terms_of_service_url) ?? '',
- }
-}
-
-function normalizeDirectoryApp(value: unknown, catalogRank = 0): DirectoryAppInfo | null {
- const record = asRecord(value)
- if (!record) return null
- const id = readString(record.id)
- const name = readString(record.name)
- if (!id || !name) return null
- const branding = asRecord(record.branding)
- const metadata = asRecord(record.appMetadata ?? record.app_metadata)
- return {
- id,
- name,
- description: readString(record.description) ?? readString(metadata?.seoDescription ?? metadata?.seo_description) ?? '',
- logoUrl: readString(record.logoUrl ?? record.logo_url) ?? '',
- logoUrlDark: readString(record.logoUrlDark ?? record.logo_url_dark) ?? '',
- distributionChannel: readString(record.distributionChannel ?? record.distribution_channel) ?? '',
- installUrl: readString(record.installUrl ?? record.install_url) ?? '',
- isAccessible: readBoolean(record.isAccessible ?? record.is_accessible) ?? false,
- isEnabled: readBoolean(record.isEnabled ?? record.is_enabled) ?? true,
- pluginDisplayNames: readStringArray(record.pluginDisplayNames ?? record.plugin_display_names),
- category: readString(branding?.category) ?? '',
- developer: readString(branding?.developer) ?? readString(metadata?.developer) ?? '',
- website: readString(branding?.website) ?? '',
- privacyPolicy: readString(branding?.privacyPolicy ?? branding?.privacy_policy) ?? '',
- termsOfService: readString(branding?.termsOfService ?? branding?.terms_of_service) ?? '',
- catalogRank,
- }
-}
-
-function normalizeDirectoryMcpServer(value: unknown): DirectoryMcpServerStatus | null {
- const record = asRecord(value)
- if (!record) return null
- const name = readString(record.name)
- if (!name) return null
- const toolsRecord = asRecord(record.tools) ?? {}
- const tools = Object.entries(toolsRecord).map(([fallbackName, raw]) => {
- const tool = asRecord(raw)
- return {
- name: readString(tool?.name) ?? fallbackName,
- title: readString(tool?.title) ?? '',
- description: readString(tool?.description) ?? '',
- }
- })
- const resources = Array.isArray(record.resources)
- ? record.resources.map((raw) => {
- const resource = asRecord(raw)
- return {
- name: readString(resource?.name) ?? '',
- title: readString(resource?.title) ?? '',
- uri: readString(resource?.uri) ?? '',
- description: readString(resource?.description) ?? '',
- }
- }).filter((resource) => resource.name || resource.uri)
- : []
- const rawResourceTemplates = record.resourceTemplates ?? record.resource_templates
- const resourceTemplates = Array.isArray(rawResourceTemplates)
- ? rawResourceTemplates.map((raw: unknown) => {
- const template = asRecord(raw)
- return {
- name: readString(template?.name) ?? '',
- title: readString(template?.title) ?? '',
- uriTemplate: readString(template?.uriTemplate ?? template?.uri_template) ?? '',
- description: readString(template?.description) ?? '',
- }
- }).filter((template) => template.name || template.uriTemplate)
- : []
-
- return {
- name,
- authStatus: readString(record.authStatus ?? record.auth_status) ?? 'unsupported',
- tools,
- resources,
- resourceTemplates,
- }
-}
-
export async function listDirectoryPlugins(cwds?: string[]): Promise {
const params: Record = {}
if (cwds && cwds.length > 0) params.cwds = cwds
diff --git a/src/api/codexGatewayDirectory.ts b/src/api/codexGatewayDirectory.ts
new file mode 100644
index 000000000..3bc47e683
--- /dev/null
+++ b/src/api/codexGatewayDirectory.ts
@@ -0,0 +1,185 @@
+import type {
+ DirectoryAppInfo,
+ DirectoryMcpServerStatus,
+ DirectoryPluginAppSummary,
+ DirectoryPluginSkillSummary,
+ DirectoryPluginSummary,
+} from './codexGateway'
+
+function asRecord(value: unknown): Record | null {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : null
+}
+
+function readString(value: unknown): string | null {
+ return typeof value === 'string' && value.length > 0 ? value : null
+}
+
+function readBoolean(value: unknown): boolean | null {
+ return typeof value === 'boolean' ? value : null
+}
+
+function readStringArray(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.length > 0) : []
+}
+
+export function normalizeDirectoryPluginApp(value: unknown): DirectoryPluginAppSummary | null {
+ const record = asRecord(value)
+ if (!record) return null
+ const id = readString(record.id)
+ const name = readString(record.name)
+ if (!id || !name) return null
+ return {
+ id,
+ name,
+ description: readString(record.description) ?? '',
+ installUrl: readString(record.installUrl ?? record.install_url) ?? '',
+ needsAuth: readBoolean(record.needsAuth ?? record.needs_auth) ?? false,
+ }
+}
+
+export function normalizeDirectoryPluginSkill(value: unknown): DirectoryPluginSkillSummary | null {
+ const record = asRecord(value)
+ if (!record) return null
+ const name = readString(record.name)
+ const path = readString(record.path)
+ if (!name || !path) return null
+ const iface = asRecord(record.interface)
+ return {
+ name,
+ path,
+ description: readString(record.description) ?? '',
+ enabled: readBoolean(record.enabled) ?? true,
+ displayName: readString(iface?.displayName ?? iface?.display_name) ?? name,
+ shortDescription: readString(record.shortDescription ?? record.short_description) ?? '',
+ }
+}
+
+export function normalizeDirectoryPluginSummary(
+ value: unknown,
+ marketplace: { name?: string; displayName?: string; path?: string | null } = {},
+): DirectoryPluginSummary | null {
+ const record = asRecord(value)
+ if (!record) return null
+ const id = readString(record.id)
+ const name = readString(record.name)
+ if (!id || !name) return null
+ const iface = asRecord(record.interface)
+ const source = asRecord(record.source)
+ const sourceType = readString(source?.type) ?? ''
+ const sourcePath = readString(source?.path)
+ const sourceUrl = readString(source?.url) ?? ''
+ const remoteMarketplaceName = sourceType === 'remote' ? marketplace.name ?? null : null
+ const marketplacePath = marketplace.path ?? (sourceType === 'local' ? sourcePath : null)
+ const displayName = readString(iface?.displayName ?? iface?.display_name) ?? name
+ const shortDescription = readString(iface?.shortDescription ?? iface?.short_description)
+ const longDescription = readString(iface?.longDescription ?? iface?.long_description) ?? ''
+
+ return {
+ id,
+ name,
+ displayName,
+ description: shortDescription ?? longDescription,
+ longDescription,
+ developerName: readString(iface?.developerName ?? iface?.developer_name) ?? '',
+ category: readString(iface?.category) ?? '',
+ marketplaceName: marketplace.name ?? '',
+ marketplaceDisplayName: marketplace.displayName ?? marketplace.name ?? '',
+ marketplacePath,
+ remoteMarketplaceName,
+ sourceType,
+ sourceUrl,
+ installed: readBoolean(record.installed) ?? false,
+ enabled: readBoolean(record.enabled) ?? true,
+ installPolicy: readString(record.installPolicy ?? record.install_policy) ?? '',
+ authPolicy: readString(record.authPolicy ?? record.auth_policy) ?? '',
+ logoUrl: readString(iface?.logoUrl ?? iface?.logo_url) ?? '',
+ logoPath: readString(iface?.logo) ?? '',
+ composerIconUrl: readString(iface?.composerIconUrl ?? iface?.composer_icon_url) ?? '',
+ composerIconPath: readString(iface?.composerIcon ?? iface?.composer_icon) ?? '',
+ brandColor: readString(iface?.brandColor ?? iface?.brand_color) ?? '',
+ capabilities: readStringArray(iface?.capabilities),
+ defaultPrompt: readStringArray(iface?.defaultPrompt ?? iface?.default_prompt),
+ screenshotUrls: readStringArray(iface?.screenshotUrls ?? iface?.screenshot_urls),
+ screenshots: readStringArray(iface?.screenshots),
+ websiteUrl: readString(iface?.websiteUrl ?? iface?.website_url) ?? '',
+ privacyPolicyUrl: readString(iface?.privacyPolicyUrl ?? iface?.privacy_policy_url) ?? '',
+ termsOfServiceUrl: readString(iface?.termsOfServiceUrl ?? iface?.terms_of_service_url) ?? '',
+ }
+}
+
+export function normalizeDirectoryApp(value: unknown, catalogRank = 0): DirectoryAppInfo | null {
+ const record = asRecord(value)
+ if (!record) return null
+ const id = readString(record.id)
+ const name = readString(record.name)
+ if (!id || !name) return null
+ const branding = asRecord(record.branding)
+ const metadata = asRecord(record.appMetadata ?? record.app_metadata)
+ return {
+ id,
+ name,
+ description: readString(record.description) ?? readString(metadata?.seoDescription ?? metadata?.seo_description) ?? '',
+ logoUrl: readString(record.logoUrl ?? record.logo_url) ?? '',
+ logoUrlDark: readString(record.logoUrlDark ?? record.logo_url_dark) ?? '',
+ distributionChannel: readString(record.distributionChannel ?? record.distribution_channel) ?? '',
+ installUrl: readString(record.installUrl ?? record.install_url) ?? '',
+ isAccessible: readBoolean(record.isAccessible ?? record.is_accessible) ?? false,
+ isEnabled: readBoolean(record.isEnabled ?? record.is_enabled) ?? true,
+ pluginDisplayNames: readStringArray(record.pluginDisplayNames ?? record.plugin_display_names),
+ category: readString(branding?.category) ?? '',
+ developer: readString(branding?.developer) ?? readString(metadata?.developer) ?? '',
+ website: readString(branding?.website) ?? '',
+ privacyPolicy: readString(branding?.privacyPolicy ?? branding?.privacy_policy) ?? '',
+ termsOfService: readString(branding?.termsOfService ?? branding?.terms_of_service) ?? '',
+ catalogRank,
+ }
+}
+
+export function normalizeDirectoryMcpServer(value: unknown): DirectoryMcpServerStatus | null {
+ const record = asRecord(value)
+ if (!record) return null
+ const name = readString(record.name)
+ if (!name) return null
+ const toolsRecord = asRecord(record.tools) ?? {}
+ const tools = Object.entries(toolsRecord).map(([fallbackName, raw]) => {
+ const tool = asRecord(raw)
+ return {
+ name: readString(tool?.name) ?? fallbackName,
+ title: readString(tool?.title) ?? '',
+ description: readString(tool?.description) ?? '',
+ }
+ })
+ const resources = Array.isArray(record.resources)
+ ? record.resources.map((raw) => {
+ const resource = asRecord(raw)
+ return {
+ name: readString(resource?.name) ?? '',
+ title: readString(resource?.title) ?? '',
+ uri: readString(resource?.uri) ?? '',
+ description: readString(resource?.description) ?? '',
+ }
+ }).filter((resource) => resource.name || resource.uri)
+ : []
+ const rawResourceTemplates = record.resourceTemplates ?? record.resource_templates
+ const resourceTemplates = Array.isArray(rawResourceTemplates)
+ ? rawResourceTemplates.map((raw: unknown) => {
+ const template = asRecord(raw)
+ return {
+ name: readString(template?.name) ?? '',
+ title: readString(template?.title) ?? '',
+ uriTemplate: readString(template?.uriTemplate ?? template?.uri_template) ?? '',
+ description: readString(template?.description) ?? '',
+ }
+ }).filter((template) => template.name || template.uriTemplate)
+ : []
+
+ return {
+ name,
+ authStatus: readString(record.authStatus ?? record.auth_status) ?? 'unsupported',
+ tools,
+ resources,
+ resourceTemplates,
+ }
+}
diff --git a/src/api/codexGatewayReview.ts b/src/api/codexGatewayReview.ts
new file mode 100644
index 000000000..553dca99e
--- /dev/null
+++ b/src/api/codexGatewayReview.ts
@@ -0,0 +1,226 @@
+import type { ThreadReadResponse } from './appServerDtos'
+import type {
+ UiReviewFile,
+ UiReviewFinding,
+ UiReviewHunk,
+ UiReviewLine,
+ UiReviewResult,
+ UiReviewSnapshot,
+} from '../types/codex'
+
+function asRecord(value: unknown): Record | null {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : null
+}
+
+function readString(value: unknown): string | null {
+ return typeof value === 'string' && value.length > 0 ? value : null
+}
+
+function readNumber(value: unknown): number | null {
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
+}
+
+function readBoolean(value: unknown): boolean | null {
+ return typeof value === 'boolean' ? value : null
+}
+
+function normalizeReviewLine(value: unknown): UiReviewLine | null {
+ const record = asRecord(value)
+ if (!record) return null
+
+ const key = readString(record.key)
+ const text = typeof record.text === 'string' ? record.text : ''
+ const kind = readString(record.kind)
+ if (!key || !kind) return null
+ if (kind !== 'meta' && kind !== 'hunk' && kind !== 'add' && kind !== 'remove' && kind !== 'context') {
+ return null
+ }
+
+ return {
+ key,
+ kind,
+ text,
+ oldLine: readNumber(record.oldLine),
+ newLine: readNumber(record.newLine),
+ }
+}
+
+function normalizeReviewHunk(value: unknown): UiReviewHunk | null {
+ const record = asRecord(value)
+ if (!record) return null
+
+ const id = readString(record.id)
+ const header = typeof record.header === 'string' ? record.header : ''
+ const patch = typeof record.patch === 'string' ? record.patch : ''
+ if (!id) return null
+
+ return {
+ id,
+ header,
+ patch,
+ addedLineCount: readNumber(record.addedLineCount) ?? 0,
+ removedLineCount: readNumber(record.removedLineCount) ?? 0,
+ oldStart: readNumber(record.oldStart),
+ oldLineCount: readNumber(record.oldLineCount) ?? 0,
+ newStart: readNumber(record.newStart),
+ newLineCount: readNumber(record.newLineCount) ?? 0,
+ lines: Array.isArray(record.lines)
+ ? record.lines
+ .map((entry) => normalizeReviewLine(entry))
+ .filter((entry): entry is UiReviewLine => entry !== null)
+ : [],
+ }
+}
+
+function normalizeReviewFile(value: unknown): UiReviewFile | null {
+ const record = asRecord(value)
+ if (!record) return null
+
+ const id = readString(record.id)
+ const path = readString(record.path)
+ const absolutePath = readString(record.absolutePath)
+ const operation = readString(record.operation)
+ if (!id || !path || !absolutePath || !operation) return null
+ if (operation !== 'add' && operation !== 'delete' && operation !== 'update' && operation !== 'rename') {
+ return null
+ }
+
+ return {
+ id,
+ path,
+ absolutePath,
+ previousPath: readString(record.previousPath),
+ previousAbsolutePath: readString(record.previousAbsolutePath),
+ operation,
+ addedLineCount: readNumber(record.addedLineCount) ?? 0,
+ removedLineCount: readNumber(record.removedLineCount) ?? 0,
+ diff: typeof record.diff === 'string' ? record.diff : '',
+ hunks: Array.isArray(record.hunks)
+ ? record.hunks
+ .map((entry) => normalizeReviewHunk(entry))
+ .filter((entry): entry is UiReviewHunk => entry !== null)
+ : [],
+ }
+}
+
+export function normalizeReviewSnapshot(payload: unknown): UiReviewSnapshot {
+ const envelope = asRecord(payload)
+ const data = asRecord(envelope?.data)
+ const summaryRecord = asRecord(data?.summary)
+ const scope = readString(data?.scope) === 'baseBranch' ? 'baseBranch' : 'workspace'
+ const workspaceView = readString(data?.workspaceView) === 'staged' ? 'staged' : 'unstaged'
+
+ return {
+ cwd: readString(data?.cwd) ?? '',
+ gitRoot: readString(data?.gitRoot),
+ isGitRepo: readBoolean(data?.isGitRepo) ?? false,
+ scope,
+ workspaceView,
+ baseBranch: readString(data?.baseBranch),
+ baseBranchOptions: Array.isArray(data?.baseBranchOptions)
+ ? data.baseBranchOptions
+ .map((entry) => readString(entry))
+ .filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
+ : [],
+ headBranch: readString(data?.headBranch),
+ mergeBaseSha: readString(data?.mergeBaseSha),
+ generatedAtIso: readString(data?.generatedAtIso) ?? '',
+ summary: {
+ fileCount: readNumber(summaryRecord?.fileCount) ?? 0,
+ addedLineCount: readNumber(summaryRecord?.addedLineCount) ?? 0,
+ removedLineCount: readNumber(summaryRecord?.removedLineCount) ?? 0,
+ },
+ files: Array.isArray(data?.files)
+ ? data.files
+ .map((entry) => normalizeReviewFile(entry))
+ .filter((entry): entry is UiReviewFile => entry !== null)
+ : [],
+ }
+}
+
+function parseReviewLocation(value: string): {
+ absolutePath: string | null
+ startLine: number | null
+ endLine: number | null
+} {
+ const trimmed = value.trim()
+ if (!trimmed) {
+ return { absolutePath: null, startLine: null, endLine: null }
+ }
+
+ const match = trimmed.match(/^(.*?):(\d+)-(\d+)$/u)
+ if (!match) {
+ return { absolutePath: trimmed || null, startLine: null, endLine: null }
+ }
+
+ return {
+ absolutePath: match[1]?.trim() || null,
+ startLine: Number(match[2]),
+ endLine: Number(match[3]),
+ }
+}
+
+export function parseReviewText(reviewText: string): UiReviewResult {
+ const normalized = reviewText.replace(/\r\n/g, '\n').trim()
+ if (!normalized) {
+ return { reviewText: '', summary: '', findings: [] }
+ }
+
+ const markerIndex = normalized.search(/\n(?:Full review comments|Review comment):\n/iu)
+ const summary = markerIndex >= 0 ? normalized.slice(0, markerIndex).trim() : normalized
+ const findingsSection = markerIndex >= 0 ? normalized.slice(markerIndex).trim() : ''
+ const findings: UiReviewFinding[] = []
+
+ if (findingsSection) {
+ const body = findingsSection
+ .replace(/^(?:Full review comments|Review comment):\n*/iu, '')
+ .trim()
+ const matches = body.matchAll(/^- (.+?) — (.+)\n?((?: .*(?:\n|$))*)/gmu)
+ let index = 0
+ for (const match of matches) {
+ const title = match[1]?.trim() ?? ''
+ const location = parseReviewLocation(match[2] ?? '')
+ const block = (match[0] ?? '').trim()
+ const findingBody = (match[3] ?? '')
+ .split('\n')
+ .map((line) => line.replace(/^ /u, ''))
+ .join('\n')
+ .trim()
+
+ findings.push({
+ id: `finding:${index}`,
+ title: title || `Finding ${index + 1}`,
+ body: findingBody,
+ path: location.absolutePath ? location.absolutePath.split('/').filter(Boolean).slice(-1)[0] ?? location.absolutePath : null,
+ absolutePath: location.absolutePath,
+ startLine: location.startLine,
+ endLine: location.endLine,
+ rawText: block,
+ })
+ index += 1
+ }
+ }
+
+ return {
+ reviewText: normalized,
+ summary,
+ findings,
+ }
+}
+
+export function readLatestReviewItem(payload: ThreadReadResponse, type: 'enteredReviewMode' | 'exitedReviewMode'): string | null {
+ const turns = Array.isArray(payload.thread.turns) ? payload.thread.turns : []
+ for (let turnIndex = turns.length - 1; turnIndex >= 0; turnIndex -= 1) {
+ const turn = turns[turnIndex]
+ const items = Array.isArray(turn?.items) ? turn.items : []
+ for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) {
+ const item = items[itemIndex]
+ if (item?.type !== type) continue
+ const review = typeof item.review === 'string' ? item.review.trim() : ''
+ if (review) return review
+ }
+ }
+ return null
+}
diff --git a/src/api/codexGatewayTerminal.ts b/src/api/codexGatewayTerminal.ts
new file mode 100644
index 000000000..183a8dd16
--- /dev/null
+++ b/src/api/codexGatewayTerminal.ts
@@ -0,0 +1,106 @@
+import { extractErrorMessage } from './codexErrors'
+import type { ThreadTerminalAttachInput, ThreadTerminalQuickCommand, ThreadTerminalSession } from './codexGateway'
+
+function asRecord(value: unknown): Record | null {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : null
+}
+
+function readString(value: unknown): string | null {
+ return typeof value === 'string' && value.length > 0 ? value : null
+}
+
+function readBoolean(value: unknown): boolean | null {
+ return typeof value === 'boolean' ? value : null
+}
+
+function normalizeThreadTerminalSession(value: unknown): ThreadTerminalSession | null {
+ const record = asRecord(value)
+ if (!record) return null
+ const id = readString(record.id)
+ const threadId = readString(record.threadId)
+ const cwd = readString(record.cwd)
+ const shell = readString(record.shell)
+ if (!id || !threadId || !cwd || !shell) return null
+ return {
+ id,
+ threadId,
+ cwd,
+ shell,
+ buffer: typeof record.buffer === 'string' ? record.buffer : '',
+ truncated: readBoolean(record.truncated) ?? false,
+ }
+}
+
+async function fetchTerminalJson(path: string, init?: RequestInit): Promise {
+ const response = await fetch(path, init)
+ const payload = await response.json().catch(() => null)
+ if (!response.ok) {
+ throw new Error(extractErrorMessage(payload, `Terminal request failed with HTTP ${response.status}`))
+ }
+ return payload
+}
+
+export async function attachThreadTerminal(input: ThreadTerminalAttachInput): Promise {
+ const payload = await fetchTerminalJson('/codex-api/thread-terminal/attach', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(input),
+ })
+ const session = normalizeThreadTerminalSession(asRecord(payload)?.session)
+ if (!session) throw new Error('Terminal attach response was malformed')
+ return session
+}
+
+export async function getThreadTerminalStatus(): Promise<{ available: boolean, reason: string | null }> {
+ const payload = await fetchTerminalJson('/codex-api/thread-terminal/status')
+ const record = asRecord(payload)
+ return {
+ available: readBoolean(record?.available) ?? false,
+ reason: readString(record?.reason) || null,
+ }
+}
+
+export async function getThreadTerminalQuickCommands(cwd: string): Promise {
+ const payload = await fetchTerminalJson(`/codex-api/thread-terminal/quick-commands?cwd=${encodeURIComponent(cwd)}`)
+ const payloadRecord = asRecord(payload)
+ const rows: unknown[] = Array.isArray(payloadRecord?.commands) ? payloadRecord.commands : []
+ return rows.flatMap((row: unknown) => {
+ const record = asRecord(row)
+ const label = readString(record?.label)
+ const value = readString(record?.value)
+ const source = readString(record?.source)
+ if (!label || !value || (source !== 'package' && source !== 'script' && source !== 'make')) return []
+ return [{ label, value, source }]
+ })
+}
+
+export async function sendThreadTerminalInput(sessionId: string, data: string): Promise {
+ await fetchTerminalJson('/codex-api/thread-terminal/input', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sessionId, data }),
+ })
+}
+
+export async function resizeThreadTerminal(sessionId: string, cols: number, rows: number): Promise {
+ await fetchTerminalJson('/codex-api/thread-terminal/resize', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sessionId, cols, rows }),
+ })
+}
+
+export async function closeThreadTerminal(sessionId: string): Promise {
+ await fetchTerminalJson('/codex-api/thread-terminal/close', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sessionId }),
+ })
+}
+
+export async function getThreadTerminalSnapshot(threadId: string): Promise {
+ const payload = await fetchTerminalJson(`/codex-api/thread-terminal-snapshot?threadId=${encodeURIComponent(threadId)}`)
+ return normalizeThreadTerminalSession(asRecord(payload)?.session)
+}
diff --git a/src/appExportMarkdown.ts b/src/appExportMarkdown.ts
new file mode 100644
index 000000000..6dff78385
--- /dev/null
+++ b/src/appExportMarkdown.ts
@@ -0,0 +1,73 @@
+import type { UiMessage, UiThread } from './types/codex'
+
+export function buildThreadMarkdown(thread: UiThread | null, messages: UiMessage[]): string {
+ const lines: string[] = []
+ const threadTitle = thread?.title?.trim() || 'Untitled thread'
+ lines.push(`# ${escapeMarkdownText(threadTitle)}`)
+ lines.push('')
+ lines.push(`- Exported: ${new Date().toISOString()}`)
+ lines.push(`- Thread ID: ${thread?.id ?? ''}`)
+ lines.push('')
+ lines.push('---')
+ lines.push('')
+
+ for (const message of messages) {
+ const roleLabel = message.role ? message.role.toUpperCase() : 'MESSAGE'
+ lines.push(`## ${roleLabel}`)
+ lines.push('')
+
+ const normalizedText = message.text.trim()
+ if (normalizedText) {
+ lines.push(normalizedText)
+ lines.push('')
+ }
+
+ if (message.commandExecution) {
+ lines.push('```text')
+ lines.push(`command: ${message.commandExecution.command}`)
+ lines.push(`status: ${message.commandExecution.status}`)
+ if (message.commandExecution.cwd) {
+ lines.push(`cwd: ${message.commandExecution.cwd}`)
+ }
+ if (message.commandExecution.exitCode !== null) {
+ lines.push(`exitCode: ${message.commandExecution.exitCode}`)
+ }
+ lines.push(message.commandExecution.aggregatedOutput || '(no output)')
+ lines.push('```')
+ lines.push('')
+ }
+
+ if (message.fileAttachments && message.fileAttachments.length > 0) {
+ lines.push('Attachments:')
+ for (const attachment of message.fileAttachments) {
+ lines.push(`- ${attachment.path}`)
+ }
+ lines.push('')
+ }
+
+ if (message.images && message.images.length > 0) {
+ lines.push('Images:')
+ for (const imageUrl of message.images) {
+ lines.push(`- ${imageUrl}`)
+ }
+ lines.push('')
+ }
+ }
+
+ return `${lines.join('\n').trimEnd()}\n`
+}
+
+export function buildExportFileName(thread: UiThread | null): string {
+ const threadTitle = thread?.title?.trim() || 'chat'
+ const sanitized = threadTitle
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ const base = sanitized || 'chat'
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
+ return `${base}-${stamp}.md`
+}
+
+export function escapeMarkdownText(value: string): string {
+ return value.replace(/([\\`*_{}\[\]()#+\-.!])/g, '\\$1')
+}
diff --git a/src/appSettingsSupport.ts b/src/appSettingsSupport.ts
new file mode 100644
index 000000000..0f073631a
--- /dev/null
+++ b/src/appSettingsSupport.ts
@@ -0,0 +1,336 @@
+export type ChatWidthMode = 'standard' | 'wide' | 'extra-wide'
+
+const IN_PROGRESS_SEND_MODE_KEY = 'codex-web-local.in-progress-send-mode.v1'
+const DARK_MODE_KEY = 'codex-web-local.dark-mode.v1'
+const CHAT_WIDTH_KEY = 'codex-web-local.chat-width.v1'
+
+export type TerminalHeaderQuickCommand = {
+ label: string
+ value: string
+ custom?: boolean
+ usageCount: number
+ lastUsedAt: number
+ sourceIndex?: number
+}
+
+export type ThreadTerminalPanelExposed = {
+ runQuickCommand: (command: string, custom?: boolean) => Promise
+}
+
+export type DirectoryTryItemPayload = {
+ kind: 'app' | 'plugin' | 'skill' | 'composio'
+ name: string
+ displayName: string
+ skillPath?: string
+ prompt?: string
+ attachedSkills?: Array<{ name: string; path: string }>
+}
+
+type ChatWidthPreset = {
+ label: string
+ columnMax: string
+ cardMax: string
+}
+
+export const CHAT_WIDTH_PRESETS: Record = {
+ standard: {
+ label: 'Standard',
+ columnMax: '45rem',
+ cardMax: '76ch',
+ },
+ wide: {
+ label: 'Wide',
+ columnMax: '72rem',
+ cardMax: '88ch',
+ },
+ 'extra-wide': {
+ label: 'Extra wide',
+ columnMax: '96rem',
+ cardMax: '96ch',
+ },
+}
+
+const WHISPER_LANGUAGES: Record = {
+ en: 'english',
+ zh: 'chinese',
+ de: 'german',
+ es: 'spanish',
+ ru: 'russian',
+ ko: 'korean',
+ fr: 'french',
+ ja: 'japanese',
+ pt: 'portuguese',
+ tr: 'turkish',
+ pl: 'polish',
+ ca: 'catalan',
+ nl: 'dutch',
+ ar: 'arabic',
+ sv: 'swedish',
+ it: 'italian',
+ id: 'indonesian',
+ hi: 'hindi',
+ fi: 'finnish',
+ vi: 'vietnamese',
+ he: 'hebrew',
+ uk: 'ukrainian',
+ el: 'greek',
+ ms: 'malay',
+ cs: 'czech',
+ ro: 'romanian',
+ da: 'danish',
+ hu: 'hungarian',
+ ta: 'tamil',
+ no: 'norwegian',
+ th: 'thai',
+ ur: 'urdu',
+ hr: 'croatian',
+ bg: 'bulgarian',
+ lt: 'lithuanian',
+ la: 'latin',
+ mi: 'maori',
+ ml: 'malayalam',
+ cy: 'welsh',
+ sk: 'slovak',
+ te: 'telugu',
+ fa: 'persian',
+ lv: 'latvian',
+ bn: 'bengali',
+ sr: 'serbian',
+ az: 'azerbaijani',
+ sl: 'slovenian',
+ kn: 'kannada',
+ et: 'estonian',
+ mk: 'macedonian',
+ br: 'breton',
+ eu: 'basque',
+ is: 'icelandic',
+ hy: 'armenian',
+ ne: 'nepali',
+ mn: 'mongolian',
+ bs: 'bosnian',
+ kk: 'kazakh',
+ sq: 'albanian',
+ sw: 'swahili',
+ gl: 'galician',
+ mr: 'marathi',
+ pa: 'punjabi',
+ si: 'sinhala',
+ km: 'khmer',
+ sn: 'shona',
+ yo: 'yoruba',
+ so: 'somali',
+ af: 'afrikaans',
+ oc: 'occitan',
+ ka: 'georgian',
+ be: 'belarusian',
+ tg: 'tajik',
+ sd: 'sindhi',
+ gu: 'gujarati',
+ am: 'amharic',
+ yi: 'yiddish',
+ lo: 'lao',
+ uz: 'uzbek',
+ fo: 'faroese',
+ ht: 'haitian creole',
+ ps: 'pashto',
+ tk: 'turkmen',
+ nn: 'nynorsk',
+ mt: 'maltese',
+ sa: 'sanskrit',
+ lb: 'luxembourgish',
+ my: 'myanmar',
+ bo: 'tibetan',
+ tl: 'tagalog',
+ mg: 'malagasy',
+ as: 'assamese',
+ tt: 'tatar',
+ haw: 'hawaiian',
+ ln: 'lingala',
+ ha: 'hausa',
+ ba: 'bashkir',
+ jw: 'javanese',
+ su: 'sundanese',
+ yue: 'cantonese',
+}
+
+export function loadBoolPref(key: string, fallback: boolean): boolean {
+ if (typeof window === 'undefined') return fallback
+ const v = window.localStorage.getItem(key)
+ if (v === null) return fallback
+ return v === '1'
+}
+
+export function loadDarkModePref(): 'system' | 'light' | 'dark' {
+ if (typeof window === 'undefined') return 'system'
+ const v = window.localStorage.getItem(DARK_MODE_KEY)
+ if (v === 'light' || v === 'dark') return v
+ return 'system'
+}
+
+export function loadInProgressSendModePref(): 'steer' | 'queue' {
+ if (typeof window === 'undefined') return 'steer'
+ const v = window.localStorage.getItem(IN_PROGRESS_SEND_MODE_KEY)
+ if (v === 'steer' || v === 'queue') return v
+ return 'queue'
+}
+
+export function loadChatWidthPref(): ChatWidthMode {
+ if (typeof window === 'undefined') return 'standard'
+ const value = window.localStorage.getItem(CHAT_WIDTH_KEY)
+ return value === 'standard' || value === 'wide' || value === 'extra-wide' ? value : 'standard'
+}
+
+export function loadDictationLanguagePref(): string {
+ if (typeof window === 'undefined') return 'auto'
+ const value = window.localStorage.getItem('codex-web-local.dictation-language.v1')?.trim() || 'auto'
+ const normalized = normalizeToWhisperLanguage(value)
+ return normalized || 'auto'
+}
+
+export function buildDictationLanguageOptions(translate: (value: string) => string, currentLanguage: string, preferredLanguages: readonly string[] = []): Array<{ value: string; label: string }> {
+ const options: Array<{ value: string; label: string }> = [{ value: 'auto', label: translate('Auto-detect') }]
+ const seen = new Set(['auto'])
+ function formatLanguageLabel(value: string): string {
+ const languageName = WHISPER_LANGUAGES[value] || value
+ const title = languageName.charAt(0).toUpperCase() + languageName.slice(1)
+ return `${title} (${value})`
+ }
+
+ for (const raw of preferredLanguages) {
+ const value = normalizeToWhisperLanguage(raw)
+ if (!value || seen.has(value)) continue
+ seen.add(value)
+ options.push({
+ value,
+ label: `Preferred: ${formatLanguageLabel(value)}`,
+ })
+ }
+
+ for (const value of Object.keys(WHISPER_LANGUAGES)) {
+ if (seen.has(value)) continue
+ seen.add(value)
+ options.push({
+ value,
+ label: formatLanguageLabel(value),
+ })
+ }
+
+ const current = currentLanguage.trim()
+ if (current && !seen.has(current)) {
+ options.push({
+ value: current,
+ label: formatLanguageLabel(current),
+ })
+ }
+
+ return options
+}
+
+export function normalizeToWhisperLanguage(raw: string): string {
+ const value = raw.trim().toLowerCase()
+ if (!value || value === 'auto') return ''
+ if (value in WHISPER_LANGUAGES) return value
+ const base = value.split('-')[0] ?? value
+ if (base in WHISPER_LANGUAGES) return base
+ return ''
+}
+
+export function applyDarkMode(mode: 'system' | 'light' | 'dark'): void {
+ const root = document.documentElement
+ if (mode === 'dark') {
+ root.classList.add('dark')
+ } else if (mode === 'light') {
+ root.classList.remove('dark')
+ } else {
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
+ root.classList.toggle('dark', prefersDark)
+ }
+}
+
+
+const TERMINAL_QUICK_COMMAND_STORAGE_KEY = 'codex-web-local.terminal-quick-commands.v1'
+
+export function buildTerminalHeaderQuickCommands(projectCommands: Array<{ label: string; value: string }>, storedCommands: TerminalHeaderQuickCommand[]): TerminalHeaderQuickCommand[] {
+ const storedByValue = new Map(storedCommands.map((command) => [command.value, command]))
+ const combined: TerminalHeaderQuickCommand[] = [
+ ...projectCommands.map((command, index) => ({
+ label: command.label,
+ value: command.value,
+ usageCount: 0,
+ lastUsedAt: 0,
+ ...(storedByValue.get(command.value) ?? {}),
+ custom: false,
+ sourceIndex: index,
+ })),
+ ]
+ return combined.sort(compareTerminalQuickCommands)
+}
+
+export function normalizeTerminalQuickCommandValue(value: string): string {
+ return value.trim().replace(/\s+/g, ' ')
+}
+
+export function compareTerminalQuickCommands(first: TerminalHeaderQuickCommand, second: TerminalHeaderQuickCommand): number {
+ if (second.usageCount !== first.usageCount) return second.usageCount - first.usageCount
+ if (second.lastUsedAt !== first.lastUsedAt) return second.lastUsedAt - first.lastUsedAt
+ const firstSource = typeof first.sourceIndex === 'number' ? first.sourceIndex : Number.MAX_SAFE_INTEGER
+ const secondSource = typeof second.sourceIndex === 'number' ? second.sourceIndex : Number.MAX_SAFE_INTEGER
+ return firstSource - secondSource
+}
+
+export function loadTerminalStoredQuickCommands(): TerminalHeaderQuickCommand[] {
+ if (typeof window === 'undefined') return []
+ try {
+ const raw = window.localStorage.getItem(TERMINAL_QUICK_COMMAND_STORAGE_KEY)
+ if (!raw) return []
+ const parsed = JSON.parse(raw) as unknown
+ if (!Array.isArray(parsed)) return []
+ const seen = new Set()
+ const commands: TerminalHeaderQuickCommand[] = []
+ for (const row of parsed) {
+ const record = row !== null && typeof row === 'object' && !Array.isArray(row)
+ ? row as Record
+ : null
+ const value = normalizeTerminalQuickCommandValue(readTerminalString(record?.value))
+ if (!value || seen.has(value)) continue
+ seen.add(value)
+ commands.push({
+ label: readTerminalString(record?.label) || value,
+ value,
+ custom: record?.custom !== false,
+ usageCount: readTerminalPositiveInteger(record?.usageCount),
+ lastUsedAt: readTerminalPositiveInteger(record?.lastUsedAt),
+ })
+ }
+ return commands
+ } catch {
+ return []
+ }
+}
+
+export function saveTerminalStoredQuickCommands(commands: TerminalHeaderQuickCommand[]): void {
+ if (typeof window === 'undefined') return
+ window.localStorage.setItem(
+ TERMINAL_QUICK_COMMAND_STORAGE_KEY,
+ JSON.stringify(commands.map((command) => ({
+ label: command.label,
+ value: command.value,
+ custom: command.custom === true,
+ usageCount: command.usageCount,
+ lastUsedAt: command.lastUsedAt,
+ }))),
+ )
+}
+
+function readTerminalString(value: unknown): string {
+ return typeof value === 'string' ? value : ''
+}
+
+function readTerminalPositiveInteger(value: unknown): number {
+ if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.trunc(value))
+ if (typeof value === 'string') {
+ const parsed = Number(value)
+ if (Number.isFinite(parsed)) return Math.max(0, Math.trunc(parsed))
+ }
+ return 0
+}
diff --git a/src/components/content/ThreadConversation.scoped.css b/src/components/content/ThreadConversation.scoped.css
new file mode 100644
index 000000000..a2ecf874c
--- /dev/null
+++ b/src/components/content/ThreadConversation.scoped.css
@@ -0,0 +1,1149 @@
+@reference "tailwindcss";
+
+.conversation-root {
+ @apply relative h-full min-h-0 min-w-0 p-0 flex flex-col overflow-y-hidden overflow-x-hidden bg-transparent border-none rounded-none;
+}
+
+.conversation-loading {
+ @apply m-0 px-6 text-sm text-slate-500;
+}
+
+.conversation-empty {
+ @apply m-0 px-6 text-sm text-slate-500;
+}
+
+.conversation-list {
+ @apply h-full min-h-0 list-none m-0 px-2 sm:px-6 py-0 overflow-y-auto overflow-x-visible flex flex-col gap-2 sm:gap-3;
+}
+
+.conversation-load-more {
+ @apply flex justify-center py-3 m-0;
+}
+
+.load-more-button {
+ @apply px-4 py-1.5 text-xs rounded-full border border-slate-300 dark:border-slate-600
+ text-slate-500 dark:text-slate-400 bg-transparent
+ hover:bg-slate-100 dark:hover:bg-slate-800
+ disabled:opacity-40 disabled:cursor-not-allowed
+ transition-colors cursor-pointer;
+}
+
+.conversation-item {
+ @apply m-0 w-full min-w-0 flex;
+}
+
+.conversation-item-request {
+ @apply justify-center;
+}
+
+.conversation-item-overlay {
+ @apply justify-center;
+}
+
+.message-row {
+ @apply relative w-full min-w-0 max-w-[min(var(--chat-column-max,45rem),100%)] mx-auto flex;
+}
+
+.message-row[data-role='user'] {
+ @apply justify-end;
+}
+
+.message-row[data-role='assistant'],
+.message-row[data-role='system'] {
+ @apply justify-start;
+}
+
+.conversation-bottom-anchor {
+ @apply h-px;
+}
+
+.jump-to-latest-button {
+ @apply absolute left-1/2 bottom-4 z-20 inline-flex h-11 w-11 -translate-x-1/2 items-center justify-center rounded-full border border-slate-300 bg-white/96 text-slate-700 shadow-lg shadow-slate-900/10 transition hover:-translate-x-1/2 hover:-translate-y-0.5 hover:bg-white hover:text-slate-900;
+}
+
+.jump-to-latest-icon {
+ transform: rotate(180deg);
+}
+
+.message-stack {
+ @apply flex flex-col w-full min-w-0;
+}
+
+.request-card {
+ @apply w-full max-w-[min(var(--chat-column-max,45rem),100%)] rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 flex flex-col gap-2;
+}
+
+.request-title {
+ @apply m-0 text-sm leading-5 font-semibold text-amber-900;
+}
+
+.request-meta {
+ @apply m-0 text-xs leading-4 text-amber-700;
+}
+
+.request-reason {
+ @apply m-0 text-sm leading-5 text-amber-900 whitespace-pre-wrap break-words;
+ overflow-wrap: anywhere;
+}
+
+.request-actions {
+ @apply flex flex-wrap gap-2;
+}
+
+.request-button {
+ @apply rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs text-amber-900 hover:bg-amber-100 transition;
+}
+
+.request-button-primary {
+ @apply border-amber-500 bg-amber-500 text-white hover:bg-amber-600;
+}
+
+.request-user-input {
+ @apply flex flex-col gap-3;
+}
+
+.request-question {
+ @apply flex flex-col gap-1;
+}
+
+.request-question-title {
+ @apply m-0 text-sm leading-5 font-medium text-amber-900;
+}
+
+.request-question-text {
+ @apply m-0 text-xs leading-4 text-amber-800;
+}
+
+.request-question-option-description {
+ @apply m-0 text-xs leading-4 text-amber-700;
+}
+
+.request-link {
+ @apply inline-flex w-fit rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs text-amber-900 hover:bg-amber-100 transition;
+}
+
+.request-select {
+ @apply h-8 rounded-md border border-amber-300 bg-white px-2 text-sm text-amber-900;
+}
+
+.request-input {
+ @apply h-8 rounded-md border border-amber-300 bg-white px-2 text-sm text-amber-900 placeholder:text-amber-500;
+}
+
+.request-checkbox-list {
+ @apply flex flex-col gap-1.5;
+}
+
+.request-checkbox-row {
+ @apply flex items-center gap-2 text-sm text-amber-900;
+}
+
+.live-overlay-inline {
+ @apply w-full max-w-[min(var(--chat-column-max,45rem),100%)] px-0 py-1 flex flex-col gap-1;
+}
+
+.live-overlay-label {
+ @apply m-0 text-sm leading-5 font-medium text-zinc-600;
+}
+
+.live-overlay-reasoning {
+ @apply m-0 text-sm leading-5 text-zinc-500 whitespace-pre-wrap break-words;
+ display: block;
+ max-height: calc(1.25rem * 5);
+ overflow: auto;
+ overflow-wrap: anywhere;
+ scrollbar-width: none;
+ mask-image: linear-gradient(to top, black 75%, transparent 100%);
+ -webkit-mask-image: linear-gradient(to top, black 75%, transparent 100%);
+}
+
+.live-overlay-reasoning::-webkit-scrollbar {
+ display: none;
+}
+
+.live-overlay-error {
+ @apply m-0 text-sm leading-5 text-rose-600 whitespace-pre-wrap;
+}
+
+.message-body {
+ @apply flex flex-col min-w-0 max-w-full;
+ width: fit-content;
+}
+
+.message-body[data-role='user'] {
+ @apply ml-auto items-end;
+ align-self: flex-end;
+}
+
+.message-toolbar {
+ @apply mt-1 self-start flex items-center gap-1 opacity-[0.01] transition-opacity duration-200;
+}
+
+.message-row:hover .message-toolbar {
+ @apply opacity-100;
+}
+
+.message-copy-button {
+ @apply inline-flex items-center gap-0.5 rounded-full border border-slate-200 bg-white/90 px-1.25 py-0.5 text-[9px] font-medium leading-none text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900;
+}
+
+.message-fork-button {
+ @apply inline-flex items-center gap-0.5 px-0.5 py-0 text-[9px] font-medium leading-none text-slate-500 transition hover:text-slate-900;
+}
+
+
+.message-copy-button[data-copied='true'] {
+ @apply border-emerald-200 bg-emerald-50 text-emerald-700;
+}
+
+.message-edit-button {
+ @apply inline-flex items-center gap-0.5 px-0.5 py-0 text-[9px] font-medium leading-none text-amber-600/70 transition hover:text-amber-700;
+}
+
+.message-fork-icon,
+.message-copy-icon,
+.message-edit-icon {
+ @apply text-[10px];
+}
+
+.message-fork-label,
+.message-copy-label,
+.message-edit-label {
+ @apply leading-none;
+}
+
+.message-image-list {
+ @apply list-none m-0 mb-2 p-0 flex flex-wrap gap-2;
+}
+
+.message-image-list[data-role='user'] {
+ @apply ml-auto justify-end;
+}
+
+.message-generated-image-list {
+ @apply gap-3;
+}
+
+.message-image-item {
+ @apply m-0;
+}
+
+.message-image-button {
+ @apply block rounded-xl overflow-hidden border border-slate-300 bg-white p-0 transition hover:border-slate-400;
+}
+
+.message-image-preview {
+ @apply block w-16 h-16 object-cover;
+}
+
+.message-generated-image-preview {
+ @apply w-auto h-auto max-w-[min(560px,85vw)] max-h-[min(460px,62vh)] object-contain bg-white;
+}
+
+.message-file-attachments {
+ @apply mb-2 flex flex-wrap gap-1.5;
+}
+
+.message-skill-attachments {
+ @apply mb-2 flex flex-wrap justify-end gap-1.5;
+}
+
+.message-file-chip {
+ @apply inline-flex items-center gap-1 rounded-md border border-zinc-200 bg-zinc-50 px-2 py-0.5 text-xs text-zinc-700;
+}
+
+.message-skill-chip {
+ @apply inline-flex max-w-full items-center gap-1.5 rounded-md border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-xs text-emerald-800 no-underline transition hover:border-emerald-300 hover:bg-emerald-100 hover:text-emerald-900;
+}
+
+.message-skill-chip-prefix {
+ @apply shrink-0 font-medium text-emerald-700;
+}
+
+.message-skill-chip-name {
+ @apply min-w-0 max-w-48 truncate font-mono;
+}
+
+.message-file-chip-icon {
+ @apply text-[10px] leading-none;
+}
+
+.message-file-chip-name {
+ @apply truncate max-w-48 font-mono;
+}
+
+.message-card {
+ @apply max-w-[min(var(--chat-card-max,76ch),100%)] px-0 py-0 bg-transparent border-none rounded-none;
+}
+
+.message-text-flow {
+ @apply flex flex-col gap-2;
+}
+
+.plan-card {
+ @apply flex max-w-[min(var(--chat-card-max,76ch),100%)] flex-col gap-3 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-slate-900;
+}
+
+.plan-card-header {
+ @apply flex items-center justify-between gap-3;
+}
+
+.plan-card-title {
+ @apply m-0 text-sm font-semibold leading-5 text-sky-900;
+}
+
+.plan-card-badge {
+ @apply inline-flex items-center rounded-full bg-sky-200 px-2 py-0.5 text-[11px] font-medium leading-4 text-sky-900;
+}
+
+.plan-card-explanation {
+ @apply text-slate-700;
+}
+
+.plan-card-markdown {
+ @apply flex flex-col gap-2;
+}
+
+.plan-card-markdown :deep(.message-text),
+.plan-card-markdown :deep(.message-heading),
+.plan-card-markdown :deep(.message-blockquote),
+.plan-card-markdown :deep(.message-list),
+.plan-card-markdown :deep(.message-table-wrap),
+.plan-card-markdown :deep(.message-code-block),
+.plan-card-markdown :deep(.message-divider) {
+ @apply m-0;
+}
+
+.plan-card-markdown :deep(.message-text) {
+ @apply text-sm leading-relaxed whitespace-pre-wrap text-slate-800;
+}
+
+.plan-card-markdown :deep(.message-heading) {
+ @apply text-slate-900 tracking-tight;
+}
+
+.plan-card-markdown :deep(.message-heading-h1) {
+ @apply text-2xl font-semibold leading-tight;
+}
+
+.plan-card-markdown :deep(.message-heading-h2) {
+ @apply text-xl font-semibold leading-tight;
+}
+
+.plan-card-markdown :deep(.message-heading-h3) {
+ @apply text-lg font-semibold leading-snug;
+}
+
+.plan-card-markdown :deep(.message-heading-h4) {
+ @apply text-base font-semibold leading-snug;
+}
+
+.plan-card-markdown :deep(.message-heading-h5) {
+ @apply text-sm font-semibold leading-snug uppercase tracking-[0.02em];
+}
+
+.plan-card-markdown :deep(.message-heading-h6) {
+ @apply text-xs font-semibold leading-snug uppercase tracking-[0.04em] text-slate-600;
+}
+
+.plan-card-markdown :deep(.message-blockquote) {
+ @apply border-l-4 border-slate-300 pl-4 py-1 text-sm leading-relaxed whitespace-pre-wrap text-slate-700 bg-slate-50/70 rounded-r-lg;
+}
+
+.plan-card-markdown :deep(.message-list) {
+ @apply pl-5 text-sm leading-relaxed text-slate-800 flex flex-col gap-1.5;
+}
+
+.plan-card-markdown :deep(.message-list-unordered) {
+ @apply list-disc;
+}
+
+.plan-card-markdown :deep(.message-list-ordered) {
+ @apply list-decimal;
+}
+
+.plan-card-markdown :deep(.message-list-item) {
+ @apply pl-1;
+}
+
+.plan-card-markdown :deep(.message-list-item-text) {
+ @apply whitespace-pre-wrap;
+}
+
+.plan-card-markdown :deep(.message-list-item-paragraph + .message-list-item-paragraph) {
+ @apply mt-2;
+}
+
+.plan-card-markdown :deep(.message-task-list) {
+ @apply list-none pl-0;
+}
+
+.plan-card-markdown :deep(.message-task-item) {
+ @apply flex items-start gap-2;
+}
+
+.plan-card-markdown :deep(.message-task-checkbox) {
+ @apply mt-0.5 text-sm leading-none text-slate-500 select-none;
+}
+
+.plan-card-markdown :deep(.message-code-block) {
+ @apply overflow-hidden rounded-xl border border-slate-200 bg-slate-950/95 text-slate-100;
+}
+
+.plan-card-markdown :deep(.message-code-language) {
+ @apply border-b border-slate-800 bg-slate-900/90 px-3 py-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-400;
+}
+
+.plan-card-markdown :deep(.message-code-pre) {
+ @apply m-0 overflow-x-auto px-3 py-3 text-[13px] leading-6;
+}
+
+.plan-card-markdown :deep(.message-inline-code) {
+ @apply rounded-md bg-slate-200/80 px-1.5 py-0.5 font-mono text-[0.9em] text-slate-900;
+}
+
+.plan-card-markdown :deep(.message-file-link) {
+ @apply text-sky-700 underline decoration-sky-300 underline-offset-2;
+}
+
+.plan-card-markdown :deep(.message-table) {
+ @apply bg-white/90;
+}
+
+.plan-step-list {
+ @apply m-0 flex list-none flex-col gap-2 p-0;
+}
+
+.plan-step-item {
+ @apply flex items-start gap-2 rounded-xl border border-white/70 bg-white/80 px-3 py-2 text-sm leading-relaxed text-slate-800;
+}
+
+.plan-step-item[data-status='completed'] {
+ @apply border-emerald-200 bg-emerald-50/80;
+}
+
+.plan-step-item[data-status='inProgress'] {
+ @apply border-amber-200 bg-amber-50/80;
+}
+
+.plan-step-status {
+ @apply mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-700;
+}
+
+.plan-step-status[data-status='completed'] {
+ @apply bg-emerald-200 text-emerald-900;
+}
+
+.plan-step-status[data-status='inProgress'] {
+ @apply bg-amber-200 text-amber-900;
+}
+
+.plan-step-text {
+ @apply min-w-0 flex-1;
+}
+
+.plan-card-actions {
+ @apply mt-3 flex justify-end;
+}
+
+.plan-card-implement-button {
+ @apply inline-flex items-center rounded-full border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-800 transition hover:border-slate-400 hover:bg-slate-50;
+}
+
+.message-text {
+ @apply m-0 text-sm leading-relaxed whitespace-pre-wrap break-words text-slate-800;
+ overflow-wrap: anywhere;
+}
+
+.message-heading {
+ @apply m-0 text-slate-900 tracking-tight;
+}
+
+.message-heading-h1 {
+ @apply text-2xl font-semibold leading-tight;
+}
+
+.message-heading-h2 {
+ @apply text-xl font-semibold leading-tight;
+}
+
+.message-heading-h3 {
+ @apply text-lg font-semibold leading-snug;
+}
+
+.message-heading-h4 {
+ @apply text-base font-semibold leading-snug;
+}
+
+.message-heading-h5 {
+ @apply text-sm font-semibold leading-snug uppercase tracking-[0.02em];
+}
+
+.message-heading-h6 {
+ @apply text-xs font-semibold leading-snug uppercase tracking-[0.04em] text-slate-600;
+}
+
+.message-blockquote {
+ @apply m-0 border-l-4 border-slate-300 pl-4 py-1 text-sm leading-relaxed whitespace-pre-wrap break-words text-slate-700 bg-slate-50/70 rounded-r-lg;
+ overflow-wrap: anywhere;
+}
+
+.message-list {
+ @apply m-0 pl-5 text-sm leading-relaxed text-slate-800 flex flex-col gap-1.5;
+}
+
+.message-list-unordered {
+ @apply list-disc;
+}
+
+.message-list-ordered {
+ @apply list-decimal;
+}
+
+.message-list-item {
+ @apply pl-1;
+}
+
+.message-list-item-content {
+ @apply flex flex-col gap-1.5;
+}
+
+.message-list-item-text {
+ @apply whitespace-pre-wrap break-words;
+ overflow-wrap: anywhere;
+}
+
+.message-list-item-paragraph + .message-list-item-paragraph {
+ @apply mt-2;
+}
+
+.message-task-list {
+ @apply list-none pl-0;
+}
+
+.message-task-item {
+ @apply flex items-start gap-2;
+}
+
+.message-task-checkbox {
+ @apply mt-0.5 text-sm leading-none text-slate-500 select-none;
+}
+
+.message-table-wrap {
+ @apply w-full overflow-x-auto;
+}
+
+.message-table {
+ @apply min-w-full border-separate border-spacing-0 overflow-hidden rounded-xl border border-slate-200 bg-white text-sm text-slate-800;
+}
+
+.message-table-head-cell,
+.message-table-cell {
+ @apply border-b border-l border-slate-200 px-3 py-2 align-top whitespace-pre-wrap break-words;
+ overflow-wrap: anywhere;
+}
+
+.message-table-head-cell:first-child,
+.message-table-cell:first-child {
+ @apply border-l-0;
+}
+
+.message-table-head-cell {
+ @apply bg-slate-100 font-semibold text-slate-900;
+}
+
+.message-table-body-row:last-child .message-table-cell {
+ @apply border-b-0;
+}
+
+.message-bold-text {
+ @apply font-semibold text-slate-900;
+}
+
+.message-italic-text {
+ @apply italic;
+}
+
+.message-strikethrough-text {
+ @apply line-through text-slate-500;
+}
+
+.message-markdown-image {
+ @apply w-auto h-auto max-w-[min(560px,85vw)] max-h-[min(460px,62vh)] object-contain bg-white;
+}
+
+.message-inline-code {
+ @apply rounded-md border border-slate-200 bg-slate-100/60 px-1.5 py-0.5 text-[0.875em] leading-[1.4] text-slate-900 font-mono;
+}
+
+.message-code-block {
+ @apply overflow-hidden rounded-xl border border-slate-200 bg-slate-950 text-slate-100;
+}
+
+.message-code-language {
+ @apply border-b border-slate-800 px-3 py-2 text-[11px] font-mono uppercase tracking-[0.08em] text-slate-400;
+}
+
+.message-code-pre {
+ @apply m-0 overflow-x-auto px-3 py-3 text-[13px] leading-relaxed font-mono whitespace-pre;
+}
+
+.message-code-pre :deep(.hljs) {
+ @apply block bg-transparent p-0 text-inherit;
+}
+
+.message-file-link {
+ @apply text-sm leading-relaxed text-[#0969da] no-underline hover:text-[#1f6feb] hover:underline underline-offset-2;
+}
+
+.file-link-context-menu {
+ @apply fixed z-50 min-w-36 rounded-lg border border-zinc-200 bg-white p-1 shadow-xl;
+}
+
+.file-link-context-menu-item {
+ @apply block w-full rounded-md px-2 py-1.5 text-left text-xs text-zinc-700 hover:bg-zinc-100;
+}
+
+.message-divider {
+ @apply m-0 border-0 h-px bg-slate-300/80;
+}
+
+.message-stack[data-role='user'] {
+ @apply items-end;
+}
+
+.message-stack[data-role='assistant'],
+.message-stack[data-role='system'] {
+ @apply items-start;
+}
+
+.message-card[data-role='user'] {
+ @apply rounded-2xl bg-slate-200 px-4 py-3 max-w-[min(560px,100%)];
+ width: fit-content;
+ margin-left: auto;
+ align-self: flex-end;
+}
+
+.automation-message-label {
+ @apply mb-2 flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500;
+}
+
+.automation-message-label code {
+ @apply rounded-full bg-white/70 px-2 py-0.5 text-[10px] normal-case tracking-normal text-slate-600;
+}
+
+.message-card[data-role='assistant'],
+.message-card[data-role='system'] {
+ @apply px-0 py-0 bg-transparent border-none rounded-none;
+}
+
+:global(.dark) .message-file-chip {
+ @apply border-zinc-700 bg-zinc-900 text-zinc-200;
+}
+
+:global(.dark) .message-skill-chip {
+ @apply border-emerald-800/70 bg-emerald-950/50 text-emerald-100;
+}
+
+:global(.dark) .message-skill-chip-prefix {
+ @apply text-emerald-300;
+}
+
+.conversation-item[data-message-type='worked'] .message-stack,
+.conversation-item[data-message-type='worked'] .message-body,
+.conversation-item[data-message-type='worked'] .message-card {
+ @apply w-full max-w-full;
+}
+
+.worked-separator-wrap {
+ @apply w-full flex flex-col gap-0;
+}
+
+.worked-separator {
+ @apply w-full flex items-center gap-3 bg-transparent border-none cursor-pointer p-0;
+}
+
+.worked-chevron {
+ @apply text-[9px] text-zinc-400 transition-transform duration-200 flex-shrink-0;
+}
+
+.worked-chevron-open {
+ transform: rotate(90deg);
+}
+
+.worked-separator-line {
+ @apply h-px bg-zinc-300/80 flex-1;
+}
+
+.worked-separator-text {
+ @apply m-0 text-sm leading-relaxed font-normal text-slate-800;
+}
+
+.worked-details {
+ @apply flex flex-col gap-1.5 pt-2;
+}
+
+.worked-cmd-item {
+ @apply flex flex-col;
+}
+
+.image-modal-backdrop {
+ @apply fixed inset-0 z-50 bg-black/40 p-6 flex items-center justify-center;
+}
+
+.image-modal-content {
+ @apply relative max-w-[min(92vw,1100px)] max-h-[92vh];
+}
+
+.image-modal-close {
+ @apply absolute top-2 right-2 z-10 w-10 h-10 rounded-full bg-white/90 text-slate-900 border border-slate-300 flex items-center justify-center;
+}
+
+.image-modal-image {
+ @apply block max-w-full max-h-[90vh] rounded-2xl shadow-2xl bg-white;
+}
+
+.icon-svg {
+ @apply w-5 h-5;
+}
+
+.cmd-row {
+ @apply w-full flex items-center gap-2 px-3 py-1.5 rounded-lg border border-zinc-200 bg-zinc-50 cursor-pointer transition text-left hover:bg-zinc-100;
+}
+
+.cmd-row.cmd-row-group {
+ @apply border-dashed border-zinc-300 bg-zinc-100/90 text-zinc-600;
+}
+
+.cmd-row.cmd-compact {
+ gap: 0.375rem;
+ padding: 0.375rem 0.625rem;
+ border-radius: 0.625rem;
+}
+
+.cmd-row.cmd-compact .cmd-chevron {
+ font-size: 9px;
+}
+
+.cmd-row.cmd-compact .cmd-label {
+ font-size: 0.75rem;
+}
+
+.cmd-row.cmd-compact .cmd-status {
+ max-width: 4.5rem;
+ font-size: 0.75rem;
+}
+
+.cmd-row.cmd-expanded {
+ @apply rounded-b-none;
+}
+
+.cmd-chevron {
+ @apply text-[10px] text-zinc-400 transition-transform duration-150 flex-shrink-0;
+}
+
+.cmd-chevron-open {
+ transform: rotate(90deg);
+}
+
+.cmd-label {
+ @apply flex-1 min-w-0 truncate text-xs font-mono text-zinc-700;
+}
+
+.cmd-group-label {
+ @apply flex-1 min-w-0 truncate text-xs font-medium text-zinc-600;
+}
+
+.cmd-status {
+ @apply max-w-24 truncate text-right text-[11px] font-medium flex-shrink-0;
+}
+
+.cmd-status-running .cmd-status {
+ @apply text-amber-600;
+}
+
+.cmd-status-ok .cmd-status {
+ @apply text-emerald-600;
+}
+
+.cmd-status-error .cmd-status {
+ @apply text-rose-600;
+}
+
+.cmd-output-wrap {
+ @apply rounded-b-lg bg-zinc-900;
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 300ms ease-out, border-color 300ms ease-out;
+ border: 1px solid transparent;
+ border-top: none;
+}
+
+.cmd-output-wrap.cmd-output-visible {
+ grid-template-rows: 1fr;
+ border-color: #e4e4e7;
+}
+
+.cmd-group-wrap {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 220ms ease-out;
+}
+
+.cmd-group-wrap.cmd-group-visible {
+ grid-template-rows: 1fr;
+}
+
+.cmd-group-inner {
+ @apply mb-1 flex min-h-0 flex-col gap-1 overflow-hidden pl-2;
+}
+
+.cmd-output-inner {
+ overflow: hidden;
+ min-height: 0;
+}
+
+.cmd-output {
+ @apply m-0 px-3 py-2 text-xs font-mono text-zinc-200 whitespace-pre-wrap break-words max-h-60 overflow-y-auto;
+}
+
+.cmd-output.cmd-output-condensed {
+ max-height: 9rem;
+}
+
+.file-change-summary-block {
+ @apply mt-3 flex flex-col gap-0;
+}
+
+.file-change-summary-block-inline {
+ @apply mt-4;
+}
+
+.file-change-summary-row {
+ @apply border-dashed;
+}
+
+.file-change-summary-label {
+ @apply flex-1 min-w-0 truncate text-xs font-medium text-zinc-700;
+}
+
+.file-change-summary-status {
+ @apply inline-flex max-w-28 items-center justify-end gap-1.5 text-right text-[11px] font-semibold text-zinc-500 flex-shrink-0;
+}
+
+.file-change-panel-inner {
+ @apply mb-1 min-h-0 overflow-hidden pl-2;
+}
+
+.file-change-list {
+ @apply m-0 flex list-none flex-col gap-0.5 rounded-xl border border-zinc-200 bg-white/80 p-1.5;
+}
+
+.file-change-item {
+ @apply flex flex-wrap items-center gap-1.5 rounded-lg px-2 py-1 text-sm text-zinc-700;
+}
+
+.file-change-badge {
+ @apply inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.08em];
+}
+
+.file-change-badge[data-operation='add'] {
+ @apply bg-emerald-50 text-emerald-700;
+}
+
+.file-change-badge[data-operation='update'] {
+ @apply bg-sky-50 text-sky-700;
+}
+
+.file-change-badge[data-operation='delete'] {
+ @apply bg-rose-50 text-rose-700;
+}
+
+.file-change-badge[data-operation='move'] {
+ @apply bg-amber-50 text-amber-700;
+}
+
+.file-change-path {
+ @apply min-w-0 break-all font-mono text-[13px];
+}
+
+.file-change-path-button {
+ @apply min-w-0 border-0 bg-transparent p-0 text-left font-mono text-[13px] text-[#0969da] hover:text-[#1f6feb] hover:underline underline-offset-2;
+}
+
+.file-change-arrow {
+ @apply text-zinc-400;
+}
+
+.file-change-delta {
+ @apply ml-auto inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-2 py-1 text-[11px] font-semibold text-zinc-600;
+}
+
+.file-change-signed-count {
+ @apply inline-flex items-center whitespace-nowrap;
+}
+
+.file-change-signed-count[data-tone='add'] {
+ @apply text-emerald-600;
+}
+
+.file-change-signed-count[data-tone='remove'] {
+ @apply text-rose-600;
+}
+
+.diff-viewer-backdrop {
+ @apply fixed inset-0 z-50 bg-black/45 p-3 sm:p-6 flex items-center justify-center;
+}
+
+.diff-viewer-shell {
+ @apply relative grid h-[min(88vh,920px)] w-[min(96vw,1320px)] grid-cols-1 overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-2xl lg:grid-cols-[320px_minmax(0,1fr)];
+}
+
+.diff-viewer-sidebar {
+ @apply flex min-h-0 flex-col border-b border-zinc-200 bg-zinc-50 lg:border-b-0 lg:border-r;
+}
+
+.diff-viewer-sidebar-header {
+ @apply flex items-center justify-between gap-3 border-b border-zinc-200 px-4 py-4;
+}
+
+.diff-viewer-sidebar-title {
+ @apply m-0 text-sm font-semibold text-zinc-900;
+}
+
+.diff-viewer-sidebar-count {
+ @apply m-0 text-xs font-medium text-zinc-500;
+}
+
+.diff-viewer-sidebar-list {
+ @apply flex min-h-0 flex-col gap-2 overflow-y-auto p-3;
+}
+
+.diff-viewer-file-button {
+ @apply flex w-full flex-col items-start gap-2 rounded-2xl border border-transparent bg-transparent px-3 py-3 text-left transition hover:border-zinc-200 hover:bg-white;
+}
+
+.diff-viewer-file-button[data-active='true'] {
+ @apply border-sky-200 bg-white shadow-sm;
+}
+
+.diff-viewer-file-label {
+ @apply break-all font-mono text-[13px] text-zinc-700;
+}
+
+.diff-viewer-file-delta {
+ @apply inline-flex items-center rounded-full bg-zinc-100 px-2.5 py-1 text-[11px] font-medium text-zinc-600;
+}
+
+.diff-viewer-main {
+ @apply flex min-h-0 flex-col bg-white;
+}
+
+.diff-viewer-toolbar {
+ @apply flex items-start justify-between gap-4 border-b border-zinc-200 px-5 py-4;
+}
+
+.diff-viewer-toolbar-actions {
+ @apply flex items-center gap-2 shrink-0;
+}
+
+.diff-viewer-title-wrap {
+ @apply min-w-0;
+}
+
+.diff-viewer-title {
+ @apply m-0 break-all text-base font-semibold text-zinc-900;
+}
+
+.diff-viewer-subtitle {
+ @apply mt-1 mb-0 text-sm text-zinc-500;
+}
+
+.diff-viewer-close {
+ @apply static shrink-0 border-zinc-200 bg-zinc-100 text-zinc-700;
+}
+
+.diff-viewer-mobile-files-button {
+ @apply inline-flex items-center rounded-full border border-zinc-200 bg-zinc-100 px-3 py-1.5 text-xs font-medium text-zinc-700;
+}
+
+.diff-viewer-empty {
+ @apply flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center;
+}
+
+.diff-viewer-empty-title {
+ @apply m-0 text-base font-semibold text-zinc-900;
+}
+
+.diff-viewer-empty-text {
+ @apply mt-2 max-w-2xl text-sm leading-relaxed text-zinc-500;
+}
+
+.diff-viewer-panel {
+ @apply flex min-h-0 flex-1 flex-col;
+}
+
+.diff-viewer-meta {
+ @apply border-b border-zinc-200 bg-zinc-50 px-5 py-2;
+}
+
+.diff-viewer-language {
+ @apply inline-flex items-center rounded-full bg-zinc-200 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-zinc-700;
+}
+
+.diff-viewer-lines {
+ @apply min-h-0 flex-1 overflow-auto bg-zinc-950;
+}
+
+.diff-viewer-line {
+ display: grid;
+ grid-template-columns: 4rem 4rem 2rem minmax(0, 1fr);
+ align-items: stretch;
+ min-width: fit-content;
+}
+
+.diff-viewer-line-number {
+ @apply border-r border-zinc-800 px-3 py-1.5 text-right font-mono text-xs text-zinc-500 select-none;
+}
+
+.diff-viewer-line-marker {
+ @apply border-r border-zinc-800 px-2 py-1.5 text-center font-mono text-xs text-zinc-500 select-none;
+}
+
+.diff-viewer-line-code {
+ @apply block whitespace-pre px-3 py-1.5 font-mono text-[12px] leading-5 text-zinc-100;
+}
+
+.diff-viewer-line[data-kind='meta'] {
+ @apply bg-zinc-900;
+}
+
+.diff-viewer-line[data-kind='meta'] .diff-viewer-line-code,
+.diff-viewer-line[data-kind='meta'] .diff-viewer-line-marker {
+ @apply text-sky-300;
+}
+
+.diff-viewer-line[data-kind='hunk'] {
+ @apply bg-sky-950/40;
+}
+
+.diff-viewer-line[data-kind='hunk'] .diff-viewer-line-code,
+.diff-viewer-line[data-kind='hunk'] .diff-viewer-line-marker {
+ @apply text-sky-300;
+}
+
+.diff-viewer-line[data-kind='add'] {
+ background: rgba(20, 83, 45, 0.38);
+}
+
+.diff-viewer-line[data-kind='add'] .diff-viewer-line-marker,
+.diff-viewer-line[data-kind='add'] .diff-viewer-line-code {
+ @apply text-emerald-200;
+}
+
+.diff-viewer-line[data-kind='remove'] {
+ background: rgba(127, 29, 29, 0.32);
+}
+
+.diff-viewer-line[data-kind='remove'] .diff-viewer-line-marker,
+.diff-viewer-line[data-kind='remove'] .diff-viewer-line-code {
+ @apply text-rose-200;
+}
+
+.diff-viewer-line[data-kind='context'] {
+ @apply bg-zinc-950;
+}
+
+.diff-viewer-line[data-kind='context'] .diff-viewer-line-code {
+ @apply text-zinc-100;
+}
+
+.diff-viewer-mobile-sheet-backdrop {
+ @apply absolute inset-0 z-20 bg-black/35 flex items-end;
+}
+
+.diff-viewer-mobile-sheet {
+ @apply w-full max-h-[70vh] rounded-t-3xl bg-white shadow-2xl border-t border-zinc-200 flex flex-col overflow-hidden;
+}
+
+.diff-viewer-mobile-sheet-handle {
+ @apply mx-auto mt-3 h-1.5 w-12 rounded-full bg-zinc-300;
+}
+
+.diff-viewer-mobile-sheet-header {
+ @apply flex items-center justify-between gap-3 px-4 pt-3 pb-2 border-b border-zinc-200;
+}
+
+.diff-viewer-mobile-sheet-list {
+ @apply flex min-h-0 flex-col gap-2 overflow-y-auto px-3 py-3;
+}
+
+.diff-viewer-sheet-enter-active,
+.diff-viewer-sheet-leave-active {
+ @apply transition-opacity duration-200;
+}
+
+.diff-viewer-sheet-enter-active .diff-viewer-mobile-sheet,
+.diff-viewer-sheet-leave-active .diff-viewer-mobile-sheet {
+ transition: transform 200ms ease;
+}
+
+.diff-viewer-sheet-enter-from,
+.diff-viewer-sheet-leave-to {
+ @apply opacity-0;
+}
+
+.diff-viewer-sheet-enter-from .diff-viewer-mobile-sheet,
+.diff-viewer-sheet-leave-to .diff-viewer-mobile-sheet {
+ transform: translateY(100%);
+}
+
+@media (max-width: 767px) {
+ .diff-viewer-backdrop {
+ @apply p-0 items-stretch;
+ }
+
+ .diff-viewer-shell {
+ @apply h-[100dvh] w-screen rounded-none border-0 shadow-none;
+ }
+
+ .diff-viewer-main {
+ @apply min-w-0;
+ }
+
+ .diff-viewer-toolbar {
+ @apply sticky top-0 z-10 bg-white px-3 py-3;
+ }
+
+ .diff-viewer-title {
+ @apply text-sm leading-5;
+ }
+
+ .diff-viewer-subtitle {
+ @apply text-xs;
+ }
+
+ .diff-viewer-meta {
+ @apply px-3 py-2;
+ }
+
+ .diff-viewer-language {
+ @apply text-[10px];
+ }
+
+ .diff-viewer-line {
+ grid-template-columns: 2.75rem 2.75rem 1.5rem minmax(0, 1fr);
+ }
+
+ .diff-viewer-line-number {
+ @apply px-1.5 py-1 text-[10px];
+ }
+
+ .diff-viewer-line-marker {
+ @apply px-1 py-1 text-[10px];
+ }
+
+ .diff-viewer-line-code {
+ @apply px-2 py-1 text-[11px] leading-5;
+ }
+}
diff --git a/src/components/content/ThreadConversation.template.html b/src/components/content/ThreadConversation.template.html
new file mode 100644
index 000000000..1bf0405df
--- /dev/null
+++ b/src/components/content/ThreadConversation.template.html
@@ -0,0 +1,866 @@
+
+ Loading messages...
+
+
+ No messages in this thread yet.
+
+
+
+
+
+ {{ isLoadingMore || isLoadingPersistedAbove ? 'Loading…' : 'Load earlier messages' }}
+
+
+
+
+
+
+
+ ▶
+ {{ commandGroupSummaryLabel(message) }}
+ {{ commandGroupSummaryStatus(message) }}
+
+
+
+
+
+ ▶
+ {{ cmd.commandExecution?.command || '(command)' }}
+ {{ commandStatusLabel(cmd) }}
+
+
+
+
+
+
+
+ ▶
+ {{ message.commandExecution?.command || '(command)' }}
+ {{ commandStatusLabel(message) }}
+
+
+
+
+
+
+
+
+
+
+
+ ▶
+
+ {{ fileChangeSummaryLabel(readStandaloneFileChangeSummary(message)) }}
+
+
+
+ {{ part.label }}
+
+
+
+
+
+
+
+
+ {{ fileChangeOperationLabel(change) }}
+
+
+ {{ displayFileChangePath(change.path) }}
+
+ →
+
+ {{ displayFileChangePath(change.movedToPath) }}
+
+
+
+ {{ part.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sent via automation
+ {{ message.automationDisplayName }}
+
+
+
+
+ ▶
+ {{ message.text }}
+
+
+
+
+
+ ▶
+ {{ cmd.commandExecution?.command || '(command)' }}
+ {{ commandStatusLabel(cmd) }}
+
+
+
+
+
+
+
+
+
+
+ {{ planStepStatusIcon(step.status) }}
+
+
+
+
+
+
+ Implement plan
+
+
+
+
+
+
+
+ {{ segment.value }}
+ {{ segment.value }}
+ {{ segment.value }}
+ {{ segment.value }}
+
+ {{ segment.displayPath }}
+
+
+ {{ segment.value }}
+
+ {{ segment.value }}
+
+
+
+
+ {{ segment.value }}
+ {{ segment.value }}
+ {{ segment.value }}
+ {{ segment.value }}
+
+ {{ segment.displayPath }}
+
+
+ {{ segment.value }}
+
+ {{ segment.value }}
+
+
+
+
+ {{ segment.value }}
+ {{ segment.value }}
+ {{ segment.value }}
+ {{ segment.value }}
+
+ {{ segment.displayPath }}
+
+
+ {{ segment.value }}
+
+ {{ segment.value }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ block.language }}
+
+
+
+ {{ block.markdown }}
+
+
+
+
+
+
+
+
+
+ ▶
+
+ {{ fileChangeSummaryLabel(readAnchoredFileChangeSummary(message)) }}
+
+
+
+ {{ part.label }}
+
+
+
+
+
+
+
+
+ {{ fileChangeOperationLabel(change) }}
+
+
+ {{ displayFileChangePath(change.path) }}
+
+ →
+
+ {{ displayFileChangePath(change.movedToPath) }}
+
+
+
+ {{ part.label }}
+
+
+
+
+
+
+
+
+
+
+
+ Edit message
+
+
+
+ Fork
+
+
+
+ {{ copiedResponseAnchorId === message.id ? 'Copied' : 'Copy' }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ liveOverlay.activityLabel }}
+
+ {{ liveOverlay.reasoningText }}
+
+ {{ liveOverlay.errorText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No diff available
+
This summary was restored from the final answer text, but the thread history does not include patch diff content for this file.
+
+
+
+
+ {{ inferDiffViewerLanguage(activeDiffViewerChange) || 'diff' }}
+
+
+
+ {{ line.oldLine ?? '' }}
+ {{ line.newLine ?? '' }}
+ {{ diffViewerMarker(line) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ fileChangeOperationLabel(change) }}
+
+
+ {{ displayFileChangePath(change.path) }}
+ → {{ displayFileChangePath(change.movedToPath) }}
+
+ {{ formatFileChangeDelta(change) }}
+
+
+
+
+
+
+
+
diff --git a/src/components/content/ThreadConversation.vue b/src/components/content/ThreadConversation.vue
index ca265d246..8d1d07887 100644
--- a/src/components/content/ThreadConversation.vue
+++ b/src/components/content/ThreadConversation.vue
@@ -1,876 +1,24 @@
-
-
- Loading messages...
-
-
- No messages in this thread yet.
-
-
-
-
-
- {{ isLoadingMore || isLoadingPersistedAbove ? 'Loading…' : 'Load earlier messages' }}
-
-
-
-
-
-
-
- ▶
- {{ commandGroupSummaryLabel(message) }}
- {{ commandGroupSummaryStatus(message) }}
-
-
-
-
-
- ▶
- {{ cmd.commandExecution?.command || '(command)' }}
- {{ commandStatusLabel(cmd) }}
-
-
-
-
-
-
-
- ▶
- {{ message.commandExecution?.command || '(command)' }}
- {{ commandStatusLabel(message) }}
-
-
-
-
-
-
-
-
-
-
-
- ▶
-
- {{ fileChangeSummaryLabel(readStandaloneFileChangeSummary(message)) }}
-
-
-
- {{ part.label }}
-
-
-
-
-
-
-
-
- {{ fileChangeOperationLabel(change) }}
-
-
- {{ displayFileChangePath(change.path) }}
-
- →
-
- {{ displayFileChangePath(change.movedToPath) }}
-
-
-
- {{ part.label }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Sent via automation
- {{ message.automationDisplayName }}
-
-
-
-
- ▶
- {{ message.text }}
-
-
-
-
-
- ▶
- {{ cmd.commandExecution?.command || '(command)' }}
- {{ commandStatusLabel(cmd) }}
-
-
-
-
-
-
-
-
-
-
- {{ planStepStatusIcon(step.status) }}
-
-
-
-
-
-
- Implement plan
-
-
-
-
-
-
-
- {{ segment.value }}
- {{ segment.value }}
- {{ segment.value }}
- {{ segment.value }}
-
- {{ segment.displayPath }}
-
-
- {{ segment.value }}
-
- {{ segment.value }}
-
-
-
-
- {{ segment.value }}
- {{ segment.value }}
- {{ segment.value }}
- {{ segment.value }}
-
- {{ segment.displayPath }}
-
-
- {{ segment.value }}
-
- {{ segment.value }}
-
-
-
-
- {{ segment.value }}
- {{ segment.value }}
- {{ segment.value }}
- {{ segment.value }}
-
- {{ segment.displayPath }}
-
-
- {{ segment.value }}
-
- {{ segment.value }}
-
-
-
-
-
-
-
-
-
-
-
-
{{ block.language }}
-
-
-
- {{ block.markdown }}
-
-
-
-
-
-
-
-
-
- ▶
-
- {{ fileChangeSummaryLabel(readAnchoredFileChangeSummary(message)) }}
-
-
-
- {{ part.label }}
-
-
-
-
-
-
-
-
- {{ fileChangeOperationLabel(change) }}
-
-
- {{ displayFileChangePath(change.path) }}
-
- →
-
- {{ displayFileChangePath(change.movedToPath) }}
-
-
-
- {{ part.label }}
-
-
-
-
-
-
-
-
-
-
-
- Edit message
-
-
-
- Fork
-
-
-
- {{ copiedResponseAnchorId === message.id ? 'Copied' : 'Copy' }}
-
-
-
-
-
-
-
-
-
-
-
- {{ liveOverlay.activityLabel }}
-
- {{ liveOverlay.reasoningText }}
-
- {{ liveOverlay.errorText }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
No diff available
-
This summary was restored from the final answer text, but the thread history does not include patch diff content for this file.
-
-
-
-
- {{ inferDiffViewerLanguage(activeDiffViewerChange) || 'diff' }}
-
-
-
- {{ line.oldLine ?? '' }}
- {{ line.newLine ?? '' }}
- {{ diffViewerMarker(line) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ fileChangeOperationLabel(change) }}
-
-
- {{ displayFileChangePath(change.path) }}
- → {{ displayFileChangePath(change.movedToPath) }}
-
- {{ formatFileChangeDelta(change) }}
-
-
-
-
-
-
-
-
-
+
-
+
diff --git a/src/components/content/threadConversationFileChanges.ts b/src/components/content/threadConversationFileChanges.ts
new file mode 100644
index 000000000..f0041d345
--- /dev/null
+++ b/src/components/content/threadConversationFileChanges.ts
@@ -0,0 +1,252 @@
+import type { UiFileChange } from '../../types/codex'
+
+export type TurnFileChangeSummary = {
+ changes: UiFileChange[]
+ sourceMessageIds: string[]
+ source: 'assistant' | 'metadata'
+}
+export type DiffViewerLineKind = 'meta' | 'hunk' | 'add' | 'remove' | 'context'
+export type DiffViewerLine = {
+ key: string
+ kind: DiffViewerLineKind
+ oldLine: number | null
+ newLine: number | null
+ text: string
+}
+
+export function fileChangeKey(change: UiFileChange): string {
+ return `${change.path}\u0000${change.movedToPath ?? ''}`
+}
+
+export function mergeFileChangeDiff(first: string, second: string): string {
+ if (!first) return second
+ if (!second || first === second) return first
+ return `${first}\n${second}`.trim()
+}
+
+export function mergeFileChangeEntry(first: UiFileChange, second: UiFileChange): UiFileChange {
+ const operation = first.operation === 'add' || second.operation === 'add'
+ ? 'add'
+ : first.operation === 'delete' || second.operation === 'delete'
+ ? 'delete'
+ : 'update'
+ return {
+ path: second.path || first.path,
+ operation,
+ movedToPath: second.movedToPath ?? first.movedToPath ?? null,
+ diff: mergeFileChangeDiff(first.diff, second.diff),
+ addedLineCount: first.addedLineCount + second.addedLineCount,
+ removedLineCount: first.removedLineCount + second.removedLineCount,
+ }
+}
+
+export function compareFileChanges(first: UiFileChange, second: UiFileChange): number {
+ const firstRank = first.operation === 'add' ? 0 : first.operation === 'update' ? 1 : 2
+ const secondRank = second.operation === 'add' ? 0 : second.operation === 'update' ? 1 : 2
+ if (firstRank !== secondRank) return firstRank - secondRank
+ const firstPath = `${first.path}\u0000${first.movedToPath ?? ''}`
+ const secondPath = `${second.path}\u0000${second.movedToPath ?? ''}`
+ return firstPath.localeCompare(secondPath)
+}
+
+export function aggregateFileChanges(changes: UiFileChange[]): UiFileChange[] {
+ const byPath = new Map()
+ for (const change of changes) {
+ const key = `${change.path}\u0000${change.movedToPath ?? ''}`
+ const previous = byPath.get(key)
+ byPath.set(key, previous ? mergeFileChangeEntry(previous, change) : { ...change })
+ }
+ return Array.from(byPath.values()).sort(compareFileChanges)
+}
+
+export function fileChangeOperationLabel(change: UiFileChange): string {
+ if (change.operation === 'update' && change.movedToPath) {
+ return change.addedLineCount > 0 || change.removedLineCount > 0 ? 'Moved + edited' : 'Moved'
+ }
+ if (change.operation === 'add') return 'Added'
+ if (change.operation === 'delete') return 'Deleted'
+ return 'Edited'
+}
+
+export function fileChangeOperationTone(change: UiFileChange): 'add' | 'delete' | 'update' | 'move' {
+ if (change.operation === 'update' && change.movedToPath) return 'move'
+ return change.operation
+}
+
+export function formatFileChangeDelta(change: UiFileChange): string {
+ const parts: string[] = []
+ if (change.addedLineCount > 0) parts.push(`+${change.addedLineCount}`)
+ if (change.removedLineCount > 0) parts.push(`-${change.removedLineCount}`)
+ return parts.join(' ')
+}
+
+export type FileChangeDeltaTone = 'add' | 'remove' | 'neutral'
+
+export type FileChangeDeltaPart = {
+ tone: FileChangeDeltaTone
+ label: string
+}
+
+export function buildFileChangeDeltaParts(addedCount: number, removedCount: number, fallbackLabel = ''): FileChangeDeltaPart[] {
+ const parts: FileChangeDeltaPart[] = []
+ if (addedCount > 0) parts.push({ tone: 'add', label: `+${addedCount}` })
+ if (removedCount > 0) parts.push({ tone: 'remove', label: `-${removedCount}` })
+ if (parts.length > 0) return parts
+ return fallbackLabel ? [{ tone: 'neutral', label: fallbackLabel }] : []
+}
+
+export function fileChangeDeltaParts(change: UiFileChange): FileChangeDeltaPart[] {
+ return buildFileChangeDeltaParts(change.addedLineCount, change.removedLineCount)
+}
+
+export function formatFileChangeCountLabel(count: number): string {
+ return count === 1 ? '1 file changed' : `${count} files changed`
+}
+
+export function summarizeFileChangeKinds(summary: TurnFileChangeSummary | null): string {
+ if (!summary || summary.changes.length === 0) return ''
+ let added = 0
+ let deleted = 0
+ let edited = 0
+ let moved = 0
+
+ for (const change of summary.changes) {
+ if (change.operation === 'add') {
+ added += 1
+ continue
+ }
+ if (change.operation === 'delete') {
+ deleted += 1
+ continue
+ }
+ if (change.movedToPath) {
+ moved += 1
+ continue
+ }
+ edited += 1
+ }
+
+ const parts: string[] = []
+ if (edited > 0) parts.push(`${edited} edited`)
+ if (added > 0) parts.push(`${added} added`)
+ if (deleted > 0) parts.push(`${deleted} deleted`)
+ if (moved > 0) parts.push(`${moved} moved`)
+ return parts.join(', ')
+}
+
+export function fileChangeSummaryLabel(summary: TurnFileChangeSummary | null): string {
+ if (!summary || summary.changes.length === 0) return 'Modified files'
+ const countLabel = formatFileChangeCountLabel(summary.changes.length)
+ const kindSummary = summarizeFileChangeKinds(summary)
+ return kindSummary ? `${countLabel} · ${kindSummary}` : countLabel
+}
+
+export function fileChangeSummaryStatusParts(summary: TurnFileChangeSummary | null): FileChangeDeltaPart[] {
+ if (!summary || summary.changes.length === 0) return []
+ const totalAdded = summary.changes.reduce((sum, change) => sum + change.addedLineCount, 0)
+ const totalRemoved = summary.changes.reduce((sum, change) => sum + change.removedLineCount, 0)
+ const fallbackLabel = summary.changes.some((change) => change.movedToPath) ? 'Moved' : 'Ready'
+ return buildFileChangeDeltaParts(totalAdded, totalRemoved, fallbackLabel)
+}
+
+export function hasStructuredUnifiedDiff(change: UiFileChange): boolean {
+ return change.operation === 'update' && /^diff --git |^@@ |^--- |^\+\+\+ |^[ +-]|^\*\*\* (Move to:|End of File)/mu.test(change.diff)
+}
+
+export function buildSyntheticDiffLines(change: UiFileChange): DiffViewerLine[] {
+ const normalized = change.diff.replace(/\r\n/g, '\n')
+ const lines = normalized.length > 0 ? normalized.split('\n') : []
+ if (lines.length > 0 && lines[lines.length - 1] === '') {
+ lines.pop()
+ }
+ return lines.map((line, index) => ({
+ key: `${fileChangeKey(change)}:synthetic:${index}`,
+ kind: change.operation === 'delete' ? 'remove' : 'add',
+ oldLine: change.operation === 'delete' ? index + 1 : null,
+ newLine: change.operation === 'delete' ? null : index + 1,
+ text: line,
+ }))
+}
+
+export function buildUnifiedDiffLines(change: UiFileChange): DiffViewerLine[] {
+ const normalized = change.diff.replace(/\r\n/g, '\n')
+ const lines = normalized.length > 0 ? normalized.split('\n') : []
+ if (lines.length > 0 && lines[lines.length - 1] === '') {
+ lines.pop()
+ }
+
+ const output: DiffViewerLine[] = []
+ let oldLine = 0
+ let newLine = 0
+
+ for (const [index, line] of lines.entries()) {
+ const hunkMatch = line.match(/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/u)
+ if (hunkMatch) {
+ oldLine = Number(hunkMatch[1])
+ newLine = Number(hunkMatch[2])
+ output.push({
+ key: `${fileChangeKey(change)}:hunk:${index}`,
+ kind: 'hunk',
+ oldLine: null,
+ newLine: null,
+ text: line,
+ })
+ continue
+ }
+
+ if (line.startsWith('+') && !line.startsWith('+++')) {
+ output.push({
+ key: `${fileChangeKey(change)}:add:${index}`,
+ kind: 'add',
+ oldLine: null,
+ newLine,
+ text: line.slice(1),
+ })
+ newLine += 1
+ continue
+ }
+
+ if (line.startsWith('-') && !line.startsWith('---')) {
+ output.push({
+ key: `${fileChangeKey(change)}:remove:${index}`,
+ kind: 'remove',
+ oldLine,
+ newLine: null,
+ text: line.slice(1),
+ })
+ oldLine += 1
+ continue
+ }
+
+ if (line.startsWith(' ')) {
+ output.push({
+ key: `${fileChangeKey(change)}:context:${index}`,
+ kind: 'context',
+ oldLine,
+ newLine,
+ text: line.slice(1),
+ })
+ oldLine += 1
+ newLine += 1
+ continue
+ }
+
+ output.push({
+ key: `${fileChangeKey(change)}:meta:${index}`,
+ kind: 'meta',
+ oldLine: null,
+ newLine: null,
+ text: line,
+ })
+ }
+
+ return output
+}
+
+export function buildDiffViewerLines(change: UiFileChange | null): DiffViewerLine[] {
+ if (!change || !change.diff.trim()) return []
+ if (hasStructuredUnifiedDiff(change)) {
+ return buildUnifiedDiffLines(change)
+ }
+ return buildSyntheticDiffLines(change)
+}
diff --git a/src/components/content/threadConversationPathHelpers.ts b/src/components/content/threadConversationPathHelpers.ts
new file mode 100644
index 000000000..8518cdbda
--- /dev/null
+++ b/src/components/content/threadConversationPathHelpers.ts
@@ -0,0 +1,202 @@
+export function isFilePath(value: string): boolean {
+ if (!value || /[\r\n]/u.test(value)) return false
+ if (value.endsWith('/') || value.endsWith('\\')) return false
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(value)) return false
+
+ const looksLikeUnixAbsolute = value.startsWith('/')
+ const looksLikeWindowsAbsolute = /^[A-Za-z]:[\\/]/u.test(value)
+ const looksLikeRelative = value.startsWith('./') || value.startsWith('../') || value.startsWith('~/')
+ if (looksLikeUnixAbsolute || looksLikeWindowsAbsolute || looksLikeRelative) return true
+
+ const looksLikeBareFilename = /^[A-Za-z0-9._@() -]+\.[A-Za-z0-9]{1,12}$/u.test(value)
+ if (looksLikeBareFilename) return true
+
+ // Bare relative paths should look like actual path segments, not arbitrary prose containing "/".
+ return /^[A-Za-z0-9._@() -]+(?:[\\/][A-Za-z0-9._@() -]+)+$/u.test(value)
+}
+
+
+export function getBasename(pathValue: string): string {
+ const normalized = pathValue.replace(/\\/gu, '/')
+ const name = normalized.split('/').filter(Boolean).pop()
+ return name || pathValue
+}
+
+
+export function normalizePathSeparators(pathValue: string): string {
+ return pathValue.replace(/\\/gu, '/')
+}
+
+
+export function normalizeFileUrlToPath(pathValue: string): string {
+ if (!pathValue.startsWith('file://')) return pathValue
+ let stripped = pathValue.replace(/^file:\/\//u, '')
+ try {
+ stripped = decodeURIComponent(stripped)
+ } catch {
+ // Keep best-effort path if decoding fails.
+ }
+ if (/^\/[A-Za-z]:\//u.test(stripped)) {
+ stripped = stripped.slice(1)
+ }
+ return stripped
+}
+
+
+export function inferHomeFromCwd(cwd: string): string {
+ const normalized = normalizePathSeparators(cwd)
+ const userMatch = normalized.match(/^\/Users\/([^/]+)/u)
+ if (userMatch) return `/Users/${userMatch[1]}`
+ const homeMatch = normalized.match(/^\/home\/([^/]+)/u)
+ if (homeMatch) return `/home/${homeMatch[1]}`
+ return ''
+}
+
+
+export function normalizePathDots(pathValue: string): string {
+ const normalized = normalizePathSeparators(pathValue)
+ if (!normalized) return normalized
+
+ let root = ''
+ let rest = normalized
+ const driveMatch = rest.match(/^([A-Za-z]:)(\/.*)?$/u)
+ if (driveMatch) {
+ root = `${driveMatch[1]}/`
+ rest = (driveMatch[2] ?? '').replace(/^\/+/u, '')
+ } else if (rest.startsWith('/')) {
+ root = '/'
+ rest = rest.slice(1)
+ }
+
+ const parts = rest.split('/').filter(Boolean)
+ const stack: string[] = []
+ for (const part of parts) {
+ if (part === '.') continue
+ if (part === '..') {
+ if (stack.length > 0) stack.pop()
+ continue
+ }
+ stack.push(part)
+ }
+
+ const joined = stack.join('/')
+ if (root) return `${root}${joined}`.replace(/\/+$/u, '') || root
+ return joined || normalized
+}
+
+
+export function resolveRelativePath(pathValue: string, cwd: string): string {
+ const normalizedPath = normalizePathSeparators(normalizeFileUrlToPath(pathValue.trim()))
+ if (!normalizedPath) return ''
+
+ const looksLikeAbsolute = normalizedPath.startsWith('/') || /^[A-Za-z]:\//u.test(normalizedPath)
+ if (looksLikeAbsolute) return normalizePathDots(normalizedPath)
+
+ if (normalizedPath.startsWith('~/')) {
+ const homeBase = inferHomeFromCwd(cwd)
+ if (homeBase) {
+ return normalizePathDots(`${homeBase}/${normalizedPath.slice(2)}`)
+ }
+ }
+
+ const base = normalizePathSeparators(cwd.trim())
+ if (!base) return normalizePathDots(normalizedPath)
+ return normalizePathDots(`${base.replace(/\/+$/u, '')}/${normalizedPath}`)
+}
+
+
+export function parseFileReference(value: string): { path: string; line: number | null } | null {
+ if (!value) return null
+
+ let pathValue = value.trim()
+ const wrapped = trimLinkWrappers(pathValue)
+ pathValue = wrapped.core.trim()
+ let line: number | null = null
+
+ const hashLineMatch = pathValue.match(/^(.*)#L(\d+)(?:C\d+)?$/u)
+ if (hashLineMatch) {
+ pathValue = hashLineMatch[1]
+ line = Number(hashLineMatch[2])
+ } else {
+ const colonLineMatch = pathValue.match(/^(.*):(\d+)(?::\d+)?$/u)
+ if (colonLineMatch) {
+ pathValue = colonLineMatch[1]
+ line = Number(colonLineMatch[2])
+ }
+ }
+
+ pathValue = normalizeFileUrlToPath(pathValue)
+ if (!isFilePath(pathValue)) return null
+ return { path: pathValue, line }
+}
+
+
+export function trimLinkWrappers(value: string): { core: string; leading: string; trailing: string } {
+ let core = value
+ let leading = ''
+ let trailing = ''
+
+ const wrapperPairs: Record = {
+ '(': ')',
+ '[': ']',
+ '{': '}',
+ '<': '>',
+ '"': '"',
+ '\'': '\'',
+ '`': '`',
+ '“': '”',
+ '‘': '’',
+ }
+
+ while (core.length > 0) {
+ const opening = core[0]
+ const closing = Object.prototype.hasOwnProperty.call(wrapperPairs, opening) ? wrapperPairs[opening] : ''
+ if (!closing || !core.endsWith(closing)) break
+ leading += opening
+ trailing += closing
+ core = core.slice(1, -1)
+ }
+
+ return { core, leading, trailing }
+}
+
+
+export function parseMarkdownLinkToken(value: string): { label: string; target: string } | null {
+ const trimmed = value.trim()
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(')')) return null
+ const labelCloseIndex = trimmed.indexOf(']')
+ if (labelCloseIndex <= 1) return null
+ if (trimmed[labelCloseIndex + 1] !== '(') return null
+ const labelRaw = trimmed.slice(1, labelCloseIndex).trim()
+ const targetRaw = trimmed.slice(labelCloseIndex + 2, -1).trim()
+ if (labelRaw.includes('\n') || targetRaw.includes('\n')) return null
+ const label = trimLinkWrappers(labelRaw).core.trim() || labelRaw
+ const target = trimLinkWrappers(targetRaw).core.trim()
+ if (!target) return null
+ return { label, target }
+}
+
+
+export function headingTag(level: number): string {
+ const normalizedLevel = Math.min(6, Math.max(1, Math.trunc(level)))
+ return `h${String(normalizedLevel)}`
+}
+
+
+export function headingClass(level: number): string {
+ switch (Math.min(6, Math.max(1, Math.trunc(level)))) {
+ case 1:
+ return 'message-heading-h1'
+ case 2:
+ return 'message-heading-h2'
+ case 3:
+ return 'message-heading-h3'
+ case 4:
+ return 'message-heading-h4'
+ case 5:
+ return 'message-heading-h5'
+ default:
+ return 'message-heading-h6'
+ }
+}
+
diff --git a/src/components/sidebar/SidebarThreadTree.scoped.css b/src/components/sidebar/SidebarThreadTree.scoped.css
new file mode 100644
index 000000000..68ebb1346
--- /dev/null
+++ b/src/components/sidebar/SidebarThreadTree.scoped.css
@@ -0,0 +1,504 @@
+@reference "tailwindcss";
+
+.thread-tree-root {
+ @apply flex flex-col;
+}
+
+.pinned-section {
+ @apply order-1 mb-1;
+}
+
+.projects-section {
+ @apply order-2;
+}
+
+.chats-section {
+ @apply order-3 mt-1;
+}
+
+.thread-tree-root.chats-first .chats-section {
+ @apply order-2;
+}
+
+.thread-tree-root.chats-first .projects-section {
+ @apply order-3;
+}
+
+.thread-tree-header-row {
+ @apply cursor-pointer;
+}
+
+.section-toggle-row {
+ @apply hover:bg-zinc-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-400;
+}
+
+.thread-tree-header {
+ @apply text-sm font-normal text-zinc-500 select-none;
+}
+
+.chats-section-actions {
+ @apply flex items-center gap-1;
+}
+
+.chats-section-action {
+ @apply h-5 w-5 rounded text-zinc-500 flex items-center justify-center transition hover:bg-zinc-200 hover:text-zinc-700;
+}
+
+.chats-section-action[aria-pressed='true'] {
+ @apply bg-zinc-200 text-zinc-800;
+}
+
+.organize-menu-wrap {
+ @apply relative;
+}
+
+.organize-menu-trigger {
+ @apply h-5 w-5 rounded text-zinc-500 flex items-center justify-center transition hover:bg-zinc-200 hover:text-zinc-700;
+}
+
+.organize-menu-panel {
+ @apply absolute right-0 top-full mt-1 z-30 min-w-44 rounded-xl border border-zinc-200 bg-white/95 p-1.5 shadow-lg backdrop-blur-sm;
+}
+
+.organize-menu-title {
+ @apply px-2 py-1 text-xs text-zinc-500;
+}
+
+.organize-menu-separator {
+ @apply my-1 h-px bg-zinc-200;
+}
+
+.organize-menu-item {
+ @apply w-full rounded-lg px-2 py-1.5 text-sm text-zinc-700 flex items-center justify-between hover:bg-zinc-100;
+}
+
+.organize-menu-item[data-active='true'] {
+ @apply bg-zinc-100 text-zinc-900;
+}
+
+.thread-start-button {
+ @apply h-5 w-5 rounded text-zinc-500 flex items-center justify-center transition hover:bg-zinc-200 hover:text-zinc-700;
+}
+
+.thread-tree-loading {
+ @apply px-3 py-2 text-sm text-zinc-500;
+}
+
+.thread-tree-no-results {
+ @apply px-3 py-2 text-sm text-zinc-400;
+}
+
+.thread-tree-groups {
+ @apply pr-0.5 relative;
+}
+
+.project-group {
+ @apply m-0 transition-shadow;
+}
+
+.project-group[data-dragging='true'] {
+ @apply shadow-lg;
+}
+
+.project-header-row {
+ @apply hover:bg-zinc-200 cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-400;
+}
+
+.project-main-button {
+ @apply min-w-0 w-full text-left rounded px-0 py-0 flex items-center gap-1.5 min-h-5 cursor-grab;
+}
+
+.project-main-button[data-dragging-handle='true'] {
+ @apply cursor-grabbing;
+}
+
+.project-icon-stack {
+ @apply relative w-4 h-4 flex items-center justify-center text-zinc-500;
+}
+
+.project-icon-folder {
+ @apply absolute inset-0 flex items-center justify-center opacity-100;
+}
+
+.project-icon-chevron {
+ @apply absolute inset-0 items-center justify-center opacity-0 hidden;
+}
+
+.project-title {
+ @apply min-w-0 flex-1 text-sm font-normal text-zinc-700 truncate select-none;
+}
+
+.project-menu-wrap {
+ @apply relative;
+}
+
+.project-hover-controls {
+ @apply flex items-center gap-1;
+}
+
+.project-menu-trigger {
+ @apply h-4 w-4 rounded p-0 text-zinc-600 flex items-center justify-center;
+}
+
+.project-menu-panel {
+ @apply absolute right-0 top-full mt-1 z-20 min-w-36 rounded-md border border-zinc-200 bg-white p-1 shadow-md flex flex-col gap-0.5;
+}
+
+.project-menu-panel[data-open-direction='up'] {
+ top: auto;
+ bottom: calc(100% + 0.25rem);
+ margin-top: 0;
+}
+
+.project-menu-item {
+ @apply rounded px-2 py-1 text-left text-sm text-zinc-700 hover:bg-zinc-100;
+}
+
+.project-menu-item-danger {
+ @apply text-rose-700 hover:bg-rose-50;
+}
+
+.project-menu-label {
+ @apply px-2 pt-1 text-xs text-zinc-500;
+}
+
+.project-menu-input {
+ @apply px-2 py-1 text-sm text-zinc-800 bg-transparent border-none outline-none;
+}
+
+.project-empty-row {
+ @apply cursor-default;
+}
+
+.project-empty-spacer {
+ @apply block w-4 h-4;
+}
+
+.project-empty {
+ @apply text-sm text-zinc-400;
+}
+
+.thread-list {
+ @apply list-none m-0 p-0 flex flex-col gap-0.5;
+}
+
+.thread-list-global {
+ @apply pr-0.5;
+}
+
+.project-group > .thread-list {
+ @apply mt-0.5;
+}
+
+.thread-row-item {
+ @apply m-0;
+}
+
+.thread-row-item[data-menu-open='true'] {
+ @apply relative z-40;
+}
+
+.thread-row {
+ @apply hover:bg-zinc-200;
+}
+
+.thread-row[data-menu-open='true'] {
+ @apply relative z-30;
+}
+
+.thread-left-stack {
+ @apply relative w-4 h-4 flex items-center justify-center;
+}
+
+.thread-delete-button {
+ @apply absolute left-0 top-1/2 -translate-y-1/2 h-4 min-w-4 rounded text-zinc-500 opacity-0 pointer-events-none transition flex items-center justify-center;
+}
+
+.thread-delete-button[data-confirming='true'] {
+ @apply z-10 h-5 min-w-16 px-1.5 bg-rose-600 text-white opacity-100 pointer-events-auto shadow-sm;
+}
+
+.thread-delete-confirm-label {
+ @apply text-[11px] font-medium leading-none;
+}
+
+.thread-main-button {
+ @apply min-w-0 w-full text-left rounded px-0 py-0 flex items-center min-h-5;
+}
+
+.thread-row-title-wrap {
+ @apply min-w-0 inline-flex w-full items-center;
+}
+
+.thread-row-title-line {
+ @apply min-w-0 inline-flex w-full items-center gap-1.5;
+}
+
+.thread-row-title {
+ @apply min-w-0 block flex-1 text-sm leading-5 font-normal text-zinc-800 truncate whitespace-nowrap;
+}
+
+.thread-row-worktree-icon {
+ @apply w-3 h-3 text-zinc-500 shrink-0;
+}
+
+.thread-row-request-chip {
+ @apply inline-flex shrink-0 items-center rounded-full border px-2.5 py-1 text-[11px] font-medium leading-none;
+}
+
+.thread-row-request-chip[data-state='approval'] {
+ @apply border-emerald-500/20 bg-emerald-500/15 text-emerald-700;
+}
+
+.thread-row-request-chip[data-state='response'] {
+ @apply border-sky-200 bg-sky-50 text-sky-700;
+}
+
+.thread-status-indicator {
+ @apply w-2.5 h-2.5 rounded-full;
+}
+
+.thread-row-time {
+ @apply block text-sm font-normal text-zinc-500;
+}
+
+.thread-menu-wrap {
+ @apply relative;
+}
+
+.thread-menu-trigger {
+ @apply h-4 w-4 rounded p-0 text-xs text-zinc-600 flex items-center justify-center;
+}
+
+.thread-menu-panel {
+ @apply absolute right-0 top-full mt-1 z-20 min-w-36 rounded-md border border-zinc-200 bg-white p-1 shadow-md flex flex-col gap-0.5;
+}
+
+.thread-menu-panel-fixed {
+ @apply fixed top-0 right-auto bottom-auto left-0 mt-0 z-50;
+}
+
+.thread-menu-panel:not(.thread-menu-panel-fixed)[data-open-direction='up'] {
+ top: auto;
+ bottom: calc(100% + 0.25rem);
+ margin-top: 0;
+}
+
+.thread-menu-item {
+ @apply rounded px-2 py-1 text-left text-sm text-zinc-700 hover:bg-zinc-100;
+}
+
+.thread-menu-item-danger {
+ @apply text-rose-700 hover:bg-rose-50;
+}
+
+.thread-icon {
+ @apply w-4 h-4;
+}
+
+.thread-show-more-row {
+ @apply mt-1;
+}
+
+.thread-show-more-spacer {
+ @apply block w-4 h-4;
+}
+
+.thread-show-more-button {
+ @apply block mx-auto rounded-lg px-2 py-0.5 text-sm font-normal text-zinc-600 transition hover:text-zinc-800 hover:bg-zinc-200;
+}
+
+.thread-row-automation-chip {
+ @apply inline-flex h-4 min-w-4 shrink-0 items-center justify-center gap-0.5 rounded-full bg-amber-100 px-1 text-amber-800;
+}
+
+.thread-row-automation-icon {
+ @apply h-3 w-3 shrink-0;
+}
+
+.thread-row-automation-count {
+ @apply text-[10px] font-semibold leading-none tabular-nums;
+}
+
+.project-header-row:hover .project-icon-folder {
+ @apply opacity-0;
+}
+
+.project-header-row:hover .project-icon-chevron {
+ @apply flex opacity-100;
+}
+
+.thread-row[data-active='true'] {
+ @apply bg-zinc-200;
+}
+
+.thread-row:hover .thread-delete-button,
+.thread-row:focus-within .thread-delete-button,
+.thread-delete-button[data-confirming='true'] {
+ @apply opacity-100 pointer-events-auto;
+}
+
+.thread-status-indicator[data-state='unread'] {
+ width: 6.6667px;
+ height: 6.6667px;
+ @apply bg-blue-600;
+}
+
+.thread-status-indicator[data-state='working'] {
+ @apply border-2 border-zinc-500 border-t-transparent bg-transparent animate-spin;
+}
+
+.thread-status-indicator[data-state='awaiting-approval'] {
+ @apply bg-emerald-500;
+}
+
+.thread-status-indicator[data-state='awaiting-response'] {
+ @apply bg-sky-500;
+}
+
+.thread-row:hover .thread-status-indicator[data-state='unread'],
+.thread-row:hover .thread-status-indicator[data-state='working'],
+.thread-row:hover .thread-status-indicator[data-state='awaiting-approval'],
+.thread-row:hover .thread-status-indicator[data-state='awaiting-response'],
+.thread-row:focus-within .thread-status-indicator[data-state='unread'],
+.thread-row:focus-within .thread-status-indicator[data-state='working'],
+.thread-row:focus-within .thread-status-indicator[data-state='awaiting-approval'],
+.thread-row:focus-within .thread-status-indicator[data-state='awaiting-response'] {
+ @apply opacity-0;
+}
+
+.rename-thread-overlay {
+ @apply fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4;
+}
+
+.rename-thread-panel {
+ @apply w-full max-w-sm rounded-xl bg-white p-4 shadow-xl
+ max-h-[90vh] flex flex-col overflow-hidden;
+}
+
+.rename-thread-title {
+ @apply m-0 text-base font-semibold text-zinc-900 shrink-0;
+}
+
+.rename-thread-subtitle {
+ @apply mt-1 mb-3 text-sm text-zinc-500 overflow-y-auto flex-1 min-h-0 min-w-0 break-words;
+}
+
+.rename-thread-input {
+ @apply w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none focus:border-zinc-500 shrink-0;
+}
+
+.rename-thread-actions {
+ @apply mt-3 flex items-center justify-end gap-2 shrink-0;
+}
+
+.rename-thread-button {
+ @apply rounded-md px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-100;
+}
+
+.rename-thread-button-primary {
+ @apply bg-zinc-900 text-white hover:bg-black;
+}
+
+.rename-thread-button-danger {
+ @apply bg-rose-600 text-white hover:bg-rose-700;
+}
+
+.automation-thread-panel {
+ @apply max-w-lg;
+}
+
+.automation-thread-field {
+ @apply mb-3 flex flex-col gap-1;
+}
+
+.automation-target-picker {
+ @apply mb-3 flex flex-col gap-2 rounded-lg border border-zinc-200 bg-zinc-50 p-2;
+}
+
+.automation-target-mode-group {
+ @apply grid grid-cols-2 gap-1 rounded-lg border border-zinc-200 bg-white p-1;
+}
+
+.automation-target-mode {
+ @apply rounded-md px-2 py-1.5 text-xs font-medium text-zinc-600 hover:bg-zinc-100;
+}
+
+.automation-target-mode.is-active {
+ @apply bg-zinc-900 text-white shadow-sm hover:bg-zinc-900;
+}
+
+.automation-target-dropdown {
+ @apply flex flex-col gap-2;
+}
+
+.automation-thread-list {
+ @apply mb-3 flex max-h-40 flex-col gap-1 overflow-y-auto rounded-lg border border-zinc-200 bg-zinc-50 p-1;
+}
+
+.automation-thread-list-item {
+ @apply flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left text-sm text-zinc-700 hover:bg-white;
+}
+
+.automation-thread-list-item.is-active {
+ @apply bg-white font-medium text-zinc-950 shadow-sm;
+}
+
+.automation-thread-list-item small {
+ @apply text-xs font-normal text-zinc-500;
+}
+
+.automation-thread-list-add {
+ @apply justify-center border border-dashed border-zinc-300 text-zinc-500;
+}
+
+.automation-thread-label {
+ @apply text-xs font-medium uppercase tracking-wide text-zinc-500;
+}
+
+.automation-thread-textarea,
+.automation-thread-select {
+ @apply w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none focus:border-zinc-500;
+}
+
+.automation-schedule-mode-group {
+ @apply grid grid-cols-3 gap-1 rounded-lg border border-zinc-200 bg-zinc-100 p-1;
+}
+
+.automation-schedule-mode {
+ @apply rounded-md px-2 py-1.5 text-xs font-medium text-zinc-600 hover:bg-white;
+}
+
+.automation-schedule-mode.is-active {
+ @apply bg-white text-zinc-950 shadow-sm;
+}
+
+.automation-schedule-row {
+ @apply mt-2 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2;
+}
+
+.automation-schedule-copy {
+ @apply text-sm text-zinc-600;
+}
+
+.automation-schedule-time,
+.automation-schedule-number,
+.automation-schedule-unit {
+ @apply rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm text-zinc-900 outline-none focus:border-zinc-500;
+}
+
+.automation-schedule-number {
+ @apply w-20;
+}
+
+.automation-schedule-preview {
+ @apply mt-1 text-xs text-zinc-500;
+}
+
+.automation-thread-error {
+ @apply mb-0 text-rose-600;
+}
+
+.automation-thread-notice {
+ @apply mb-0 text-emerald-600;
+}
diff --git a/src/components/sidebar/SidebarThreadTree.vue b/src/components/sidebar/SidebarThreadTree.vue
index d37a09dc3..a49e4a360 100644
--- a/src/components/sidebar/SidebarThreadTree.vue
+++ b/src/components/sidebar/SidebarThreadTree.vue
@@ -2942,509 +2942,4 @@ onBeforeUnmount(() => {
})
-
+
diff --git a/src/composables/desktopStateNotificationReaders.ts b/src/composables/desktopStateNotificationReaders.ts
new file mode 100644
index 000000000..41a157e8a
--- /dev/null
+++ b/src/composables/desktopStateNotificationReaders.ts
@@ -0,0 +1,798 @@
+import type { RpcNotification } from '../api/codexGateway'
+import type {
+ CommandExecutionData,
+ UiMessage,
+ UiPlanData,
+ UiPlanStep,
+ UiRateLimitSnapshot,
+ UiServerRequest,
+ UiThreadTokenUsage,
+ UiTokenUsageBreakdown,
+} from '../types/codex'
+import { clamp } from './desktopStateStorage'
+import { parseIsoTimestamp, type TurnActivityState, type TurnCompletedInfo, type TurnStartedInfo } from './desktopStateThreadHelpers'
+
+const GLOBAL_SERVER_REQUEST_SCOPE = '__global__'
+
+export function normalizePlanStepStatus(value: unknown): UiPlanStep['status'] {
+ if (value === 'completed') return 'completed'
+ if (value === 'inProgress' || value === 'in_progress') return 'inProgress'
+ return 'pending'
+}
+
+
+export function buildPlanMessageText(plan: UiPlanData): string {
+ const lines: string[] = []
+ if (plan.explanation?.trim()) {
+ lines.push(plan.explanation.trim())
+ }
+ for (const step of plan.steps) {
+ const marker = step.status === 'completed' ? 'x' : step.status === 'inProgress' ? '~' : ' '
+ lines.push(`- [${marker}] ${step.step}`)
+ }
+ return lines.join('\n').trim()
+}
+
+
+export function asRecord(value: unknown): Record | null {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : null
+}
+
+
+export function readString(value: unknown): string {
+ return typeof value === 'string' ? value : ''
+}
+
+
+export function readNumber(value: unknown): number | null {
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
+}
+
+
+export function getRateLimitSnapshotKey(snapshot: UiRateLimitSnapshot): string {
+ return snapshot.limitId?.trim() || snapshot.limitName?.trim() || '__default__'
+}
+
+
+export function normalizeRateLimitWindow(value: unknown): UiRateLimitSnapshot['primary'] {
+ const record = asRecord(value)
+ if (!record) return null
+
+ const windowValue = readNumber(record.windowDurationMins)
+ return {
+ usedPercent: clamp(readNumber(record.usedPercent) ?? 0, 0, 100),
+ windowDurationMins: windowValue,
+ windowMinutes: windowValue,
+ resetsAt: readNumber(record.resetsAt),
+ }
+}
+
+
+export function normalizeRateLimitSnapshot(value: unknown): UiRateLimitSnapshot | null {
+ const record = asRecord(value)
+ if (!record) return null
+
+ const credits = asRecord(record.credits)
+ return {
+ limitId: readString(record.limitId) || null,
+ limitName: readString(record.limitName) || null,
+ primary: normalizeRateLimitWindow(record.primary),
+ secondary: normalizeRateLimitWindow(record.secondary),
+ credits: credits
+ ? {
+ hasCredits: credits.hasCredits === true,
+ unlimited: credits.unlimited === true,
+ balance: readString(credits.balance) || null,
+ }
+ : null,
+ planType: readString(record.planType) || null,
+ }
+}
+
+
+export function normalizeRateLimitSnapshotsPayload(value: unknown): UiRateLimitSnapshot[] {
+ const record = asRecord(value)
+ if (!record) return []
+
+ const next: UiRateLimitSnapshot[] = []
+ const seen = new Set()
+ const pushSnapshot = (snapshot: UiRateLimitSnapshot | null): void => {
+ if (!snapshot) return
+ const key = getRateLimitSnapshotKey(snapshot)
+ if (seen.has(key)) return
+ seen.add(key)
+ next.push(snapshot)
+ }
+
+ pushSnapshot(normalizeRateLimitSnapshot(record.rateLimits))
+
+ const byLimitId = asRecord(record.rateLimitsByLimitId)
+ if (byLimitId) {
+ for (const snapshot of Object.values(byLimitId)) {
+ pushSnapshot(normalizeRateLimitSnapshot(snapshot))
+ }
+ }
+
+ return next
+}
+
+
+export function normalizeTokenUsageBreakdown(value: unknown): UiTokenUsageBreakdown | null {
+ const record = asRecord(value)
+ if (!record) return null
+
+ const totalTokens = readNumber(record.totalTokens ?? record.total_tokens)
+ const inputTokens = readNumber(record.inputTokens ?? record.input_tokens)
+ const cachedInputTokens = readNumber(record.cachedInputTokens ?? record.cached_input_tokens)
+ const outputTokens = readNumber(record.outputTokens ?? record.output_tokens)
+ const reasoningOutputTokens = readNumber(record.reasoningOutputTokens ?? record.reasoning_output_tokens)
+ if (
+ totalTokens === null ||
+ inputTokens === null ||
+ cachedInputTokens === null ||
+ outputTokens === null ||
+ reasoningOutputTokens === null
+ ) {
+ return null
+ }
+
+ return {
+ totalTokens,
+ inputTokens,
+ cachedInputTokens,
+ outputTokens,
+ reasoningOutputTokens,
+ }
+}
+
+
+export function normalizeThreadTokenUsage(value: unknown): UiThreadTokenUsage | null {
+ const record = asRecord(value)
+ if (!record) return null
+
+ const total = normalizeTokenUsageBreakdown(record.total)
+ const last = normalizeTokenUsageBreakdown(record.last)
+ if (!total || !last) return null
+
+ const modelContextWindow = readNumber(record.modelContextWindow ?? record.model_context_window)
+ const currentContextTokens = last.totalTokens
+ const remainingContextTokens = typeof modelContextWindow === 'number'
+ ? Math.max(modelContextWindow - currentContextTokens, 0)
+ : null
+ const remainingContextPercent = typeof modelContextWindow === 'number' && modelContextWindow > 0
+ ? clamp(Math.round((remainingContextTokens ?? 0) / modelContextWindow * 100), 0, 100)
+ : null
+
+ return {
+ total,
+ last,
+ modelContextWindow,
+ currentContextTokens,
+ remainingContextTokens,
+ remainingContextPercent,
+ }
+}
+
+
+export function readThreadTokenUsageUpdate(notification: RpcNotification): { threadId: string; usage: UiThreadTokenUsage } | null {
+ if (notification.method !== 'thread/tokenUsage/updated') return null
+ const params = asRecord(notification.params)
+ const threadId = extractThreadIdFromNotification(notification)
+ const usage = normalizeThreadTokenUsage(params?.tokenUsage ?? params?.token_usage)
+ if (!threadId || !usage) return null
+ return { threadId, usage }
+}
+
+
+export function extractThreadIdFromNotification(notification: RpcNotification): string {
+ const params = asRecord(notification.params)
+ if (!params) return ''
+
+ const directThreadId = readString(params.threadId)
+ if (directThreadId) return directThreadId
+ const snakeThreadId = readString(params.thread_id)
+ if (snakeThreadId) return snakeThreadId
+
+ const conversationId = readString(params.conversationId)
+ if (conversationId) return conversationId
+ const snakeConversationId = readString(params.conversation_id)
+ if (snakeConversationId) return snakeConversationId
+
+ const thread = asRecord(params.thread)
+ const nestedThreadId = readString(thread?.id)
+ if (nestedThreadId) return nestedThreadId
+
+ const turn = asRecord(params.turn)
+ const turnThreadId = readString(turn?.threadId)
+ if (turnThreadId) return turnThreadId
+ const turnSnakeThreadId = readString(turn?.thread_id)
+ if (turnSnakeThreadId) return turnSnakeThreadId
+
+ return ''
+}
+
+
+export function readTurnErrorMessage(notification: RpcNotification): string {
+ if (notification.method !== 'turn/completed') return ''
+ const params = asRecord(notification.params)
+ const turn = asRecord(params?.turn)
+ if (!turn || turn.status !== 'failed') return ''
+ const errorPayload = asRecord(turn.error)
+ return readString(errorPayload?.message)
+}
+
+
+export function readNotificationErrorState(notification: RpcNotification): { message: string; transient: boolean } | null {
+ if (notification.method !== 'error') return null
+ const params = asRecord(notification.params)
+ const message = (
+ readString(params?.message) ||
+ readString(asRecord(params?.error)?.message)
+ )
+ if (!message) return null
+
+ return {
+ message,
+ transient: params?.willRetry === true,
+ }
+}
+
+
+export function normalizeServerRequest(params: unknown): UiServerRequest | null {
+ const row = asRecord(params)
+ if (!row) return null
+
+ const id = row.id
+ const rawMethod = readString(row.method)
+ const requestParams = row.params
+ if (typeof id !== 'number' || !Number.isInteger(id) || !rawMethod) {
+ return null
+ }
+
+ const requestParamRecord = asRecord(requestParams)
+ const method = normalizePendingServerRequestMethod(rawMethod, requestParamRecord)
+ const threadId = (
+ readString(requestParamRecord?.threadId) ||
+ readString(requestParamRecord?.thread_id) ||
+ readString(requestParamRecord?.conversationId) ||
+ readString(requestParamRecord?.conversation_id) ||
+ GLOBAL_SERVER_REQUEST_SCOPE
+ )
+ const turnId = readString(requestParamRecord?.turnId) || readString(requestParamRecord?.turn_id)
+ const itemId = (
+ readString(requestParamRecord?.itemId) ||
+ readString(requestParamRecord?.item_id) ||
+ readString(requestParamRecord?.callId) ||
+ readString(requestParamRecord?.call_id)
+ )
+ const receivedAtIso = readString(row.receivedAtIso) || new Date().toISOString()
+
+ return {
+ id,
+ method,
+ threadId,
+ turnId,
+ itemId,
+ receivedAtIso,
+ params: requestParams ?? null,
+ }
+}
+
+
+export function normalizePendingServerRequestMethod(
+ method: string,
+ params: Record | null,
+): string {
+ const normalized = method.trim()
+ if (!normalized) return normalized
+
+ if (
+ normalized === 'item/commandExecution/requestApproval' ||
+ normalized === 'execCommandApproval' ||
+ normalized === 'exec_approval_request' ||
+ looksLikeExecApprovalRequest(params)
+ ) {
+ return 'item/commandExecution/requestApproval'
+ }
+
+ if (
+ normalized === 'item/fileChange/requestApproval' ||
+ normalized === 'applyPatchApproval' ||
+ normalized === 'apply_patch_approval_request' ||
+ looksLikePatchApprovalRequest(params)
+ ) {
+ return 'item/fileChange/requestApproval'
+ }
+
+ if (
+ normalized === 'item/tool/requestUserInput' ||
+ normalized === 'request_user_input' ||
+ looksLikeToolUserInputRequest(params)
+ ) {
+ return 'item/tool/requestUserInput'
+ }
+
+ if (
+ normalized === 'mcpServer/elicitation/request' ||
+ normalized === 'elicitation_request' ||
+ looksLikeMcpServerElicitationRequest(params)
+ ) {
+ return 'mcpServer/elicitation/request'
+ }
+
+ if (normalized === 'item/permissions/requestApproval' || looksLikePermissionsApprovalRequest(params)) {
+ return 'item/permissions/requestApproval'
+ }
+
+ if (
+ normalized === 'item/tool/call' ||
+ normalized === 'dynamic_tool_call_request' ||
+ looksLikeToolCallRequest(params)
+ ) {
+ return 'item/tool/call'
+ }
+
+ return normalized
+}
+
+
+export function looksLikeExecApprovalRequest(params: Record | null): boolean {
+ if (!params) return false
+ const command = params.command
+ if (Array.isArray(command) && command.some((part) => typeof part === 'string' && part.trim().length > 0)) {
+ return true
+ }
+ if (typeof command === 'string' && command.trim().length > 0) {
+ return true
+ }
+ return Array.isArray(params.commandActions)
+}
+
+
+export function looksLikePatchApprovalRequest(params: Record | null): boolean {
+ if (!params) return false
+ if (typeof params.grantRoot === 'string' && params.grantRoot.trim().length > 0) return true
+ if (typeof params.grant_root === 'string' && params.grant_root.trim().length > 0) return true
+ if (asRecord(params.fileChanges)) return true
+ return asRecord(params.changes) !== null
+}
+
+
+export function looksLikeToolUserInputRequest(params: Record | null): boolean {
+ return Boolean(params && Array.isArray(params.questions))
+}
+
+
+export function looksLikeToolCallRequest(params: Record | null): boolean {
+ if (!params) return false
+ return (
+ typeof params.toolName === 'string' ||
+ typeof params.tool_name === 'string' ||
+ typeof params.name === 'string' ||
+ Array.isArray(params.arguments)
+ )
+}
+
+
+export function looksLikeMcpServerElicitationRequest(params: Record | null): boolean {
+ if (!params) return false
+ const mode = readString(params.mode)
+ return (
+ typeof params.serverName === 'string' &&
+ typeof params.threadId === 'string' &&
+ typeof params.message === 'string' &&
+ (mode === 'form' || mode === 'url')
+ )
+}
+
+
+export function looksLikePermissionsApprovalRequest(params: Record | null): boolean {
+ if (!params) return false
+ return (
+ typeof params.threadId === 'string' &&
+ typeof params.turnId === 'string' &&
+ typeof params.itemId === 'string' &&
+ asRecord(params.permissions) !== null
+ )
+}
+
+
+export function readToolRequestUserInputQuestionIds(request: UiServerRequest): string[] {
+ if (request.method !== 'item/tool/requestUserInput') return []
+ const params = asRecord(request.params)
+ const questions = Array.isArray(params?.questions) ? params.questions : []
+ const questionIds: string[] = []
+
+ for (const row of questions) {
+ const question = asRecord(row)
+ const id = readString(question?.id).trim()
+ if (id) {
+ questionIds.push(id)
+ }
+ }
+
+ return questionIds
+}
+
+
+export function sanitizeDisplayText(value: string): string {
+ return value.replace(/\s+/gu, ' ').trim()
+}
+
+
+export function readTurnActivity(notification: RpcNotification): { threadId: string; activity: TurnActivityState } | null {
+ const threadId = extractThreadIdFromNotification(notification)
+ if (!threadId) return null
+
+ if (notification.method === 'turn/started') {
+ return {
+ threadId,
+ activity: {
+ label: 'Thinking',
+ details: [],
+ },
+ }
+ }
+
+ if (notification.method === 'item/started') {
+ const params = asRecord(notification.params)
+ const item = asRecord(params?.item)
+ const itemType = readString(item?.type).toLowerCase()
+ if (itemType === 'reasoning') {
+ return {
+ threadId,
+ activity: {
+ label: 'Thinking',
+ details: [],
+ },
+ }
+ }
+ if (itemType === 'agentmessage') {
+ return {
+ threadId,
+ activity: {
+ label: 'Writing response',
+ details: [],
+ },
+ }
+ }
+ if (itemType === 'commandexecution') {
+ const cmd = readString(item?.command)
+ return {
+ threadId,
+ activity: {
+ label: 'Running command',
+ details: cmd ? [cmd] : [],
+ },
+ }
+ }
+ if (itemType === 'filechange') {
+ const changes = Array.isArray(item?.changes) ? item.changes : []
+ const firstChange = changes[0] as Record | undefined
+ const path = readString(firstChange?.path)
+ return {
+ threadId,
+ activity: {
+ label: 'Applying changes',
+ details: path ? [path] : [],
+ },
+ }
+ }
+ }
+
+ if (notification.method === 'item/commandExecution/outputDelta') {
+ return {
+ threadId,
+ activity: {
+ label: 'Running command',
+ details: [],
+ },
+ }
+ }
+
+ if (notification.method === 'item/fileChange/outputDelta') {
+ return {
+ threadId,
+ activity: {
+ label: 'Applying changes',
+ details: [],
+ },
+ }
+ }
+
+ if (
+ notification.method === 'item/reasoning/summaryTextDelta' ||
+ notification.method === 'item/reasoning/summaryPartAdded'
+ ) {
+ return {
+ threadId,
+ activity: {
+ label: 'Thinking',
+ details: [],
+ },
+ }
+ }
+
+ if (notification.method === 'item/agentMessage/delta') {
+ return {
+ threadId,
+ activity: {
+ label: 'Writing response',
+ details: [],
+ },
+ }
+ }
+
+ return null
+}
+
+
+export function readTurnStartedInfo(notification: RpcNotification): TurnStartedInfo | null {
+ if (notification.method !== 'turn/started') {
+ return null
+ }
+
+ const params = asRecord(notification.params)
+ if (!params) return null
+ const threadId = extractThreadIdFromNotification(notification)
+ if (!threadId) return null
+
+ const turnPayload = asRecord(params.turn)
+ const turnId =
+ readString(turnPayload?.id) ||
+ readString(params.turnId) ||
+ `${threadId}:unknown`
+ if (!turnId) return null
+
+ const startedAtMs =
+ parseIsoTimestamp(readString(turnPayload?.startedAt)) ??
+ parseIsoTimestamp(readString(params.startedAt)) ??
+ parseIsoTimestamp(notification.atIso) ??
+ Date.now()
+
+ return {
+ threadId,
+ turnId,
+ startedAtMs,
+ }
+}
+
+
+export function readTurnCompletedInfo(notification: RpcNotification): TurnCompletedInfo | null {
+ if (notification.method !== 'turn/completed') {
+ return null
+ }
+
+ const params = asRecord(notification.params)
+ if (!params) return null
+ const threadId = extractThreadIdFromNotification(notification)
+ if (!threadId) return null
+
+ const turnPayload = asRecord(params.turn)
+ const turnId =
+ readString(turnPayload?.id) ||
+ readString(params.turnId) ||
+ `${threadId}:unknown`
+ if (!turnId) return null
+
+ const completedAtMs =
+ parseIsoTimestamp(readString(turnPayload?.completedAt)) ??
+ parseIsoTimestamp(readString(params.completedAt)) ??
+ parseIsoTimestamp(notification.atIso) ??
+ Date.now()
+
+ const startedAtMs =
+ parseIsoTimestamp(readString(turnPayload?.startedAt)) ??
+ parseIsoTimestamp(readString(params.startedAt)) ??
+ undefined
+
+ return {
+ threadId,
+ turnId,
+ completedAtMs,
+ startedAtMs,
+ }
+}
+
+
+export function liveReasoningMessageId(reasoningItemId: string): string {
+ return `${reasoningItemId}:live-reasoning`
+}
+
+
+export function readReasoningStartedItemId(notification: RpcNotification): string {
+ const params = asRecord(notification.params)
+ if (!params) return ''
+
+ if (notification.method === 'item/started') {
+ const item = asRecord(params.item)
+ if (!item || item.type !== 'reasoning') return ''
+ return readString(item.id)
+ }
+
+ return ''
+}
+
+
+export function readReasoningDelta(notification: RpcNotification): { messageId: string; delta: string } | null {
+ const params = asRecord(notification.params)
+ if (!params) return null
+
+ // Канонический источник дельт для UI — уже нормализованный item/*.
+ if (notification.method === 'item/reasoning/summaryTextDelta') {
+ const itemId = readString(params.itemId)
+ const delta = readString(params.delta)
+ if (!itemId || !delta) return null
+ return { messageId: liveReasoningMessageId(itemId), delta }
+ }
+
+ return null
+}
+
+
+export function readReasoningSectionBreakMessageId(notification: RpcNotification): string {
+ const params = asRecord(notification.params)
+ if (!params) return ''
+
+ // Канонический source для section break — item/*
+ if (notification.method === 'item/reasoning/summaryPartAdded') {
+ const itemId = readString(params.itemId)
+ if (!itemId) return ''
+ return liveReasoningMessageId(itemId)
+ }
+
+ return ''
+}
+
+
+export function readReasoningCompletedId(notification: RpcNotification): string {
+ const params = asRecord(notification.params)
+ if (!params) return ''
+
+ if (notification.method === 'item/completed') {
+ const item = asRecord(params.item)
+ if (!item || item.type !== 'reasoning') return ''
+ return liveReasoningMessageId(readString(item.id))
+ }
+
+ return ''
+}
+
+
+export function readAgentMessageStartedId(notification: RpcNotification): string {
+ const params = asRecord(notification.params)
+ if (!params) return ''
+
+ if (notification.method === 'item/started') {
+ const item = asRecord(params.item)
+ if (!item || item.type !== 'agentMessage') return ''
+ return readString(item.id)
+ }
+
+ return ''
+}
+
+
+export function readAgentMessageDelta(notification: RpcNotification): { messageId: string; delta: string } | null {
+ const params = asRecord(notification.params)
+ if (!params) return null
+
+ // Канонический live-канал агентского текста.
+ if (notification.method === 'item/agentMessage/delta') {
+ const messageId = readString(params.itemId)
+ const delta = readString(params.delta)
+ if (!messageId || !delta) return null
+ return { messageId, delta }
+ }
+
+ return null
+}
+
+
+export function readAgentMessageCompleted(notification: RpcNotification): UiMessage | null {
+ const params = asRecord(notification.params)
+ if (!params) return null
+
+ if (notification.method === 'item/completed') {
+ const item = asRecord(params.item)
+ if (!item || item.type !== 'agentMessage') return null
+ const id = readString(item.id)
+ const text = readString(item.text)
+ if (!id || !text) return null
+ return {
+ id,
+ role: 'assistant',
+ text,
+ messageType: 'agentMessage.live',
+ }
+ }
+
+ return null
+}
+
+
+export function toLocalImageUrl(path: string): string {
+ return `/codex-local-image?path=${encodeURIComponent(path)}`
+}
+
+
+export function toImageGenerationUrl(value: string): string {
+ const trimmed = value.trim()
+ if (!trimmed) return ''
+ if (
+ trimmed.startsWith('data:') ||
+ trimmed.startsWith('http://') ||
+ trimmed.startsWith('https://') ||
+ trimmed.startsWith('/codex-local-image?')
+ ) {
+ return trimmed
+ }
+ const compact = trimmed.replace(/\s+/gu, '')
+ if (!/^[A-Za-z0-9+/]+={0,2}$/u.test(compact)) return ''
+ return `data:image/png;base64,${compact}`
+}
+
+
+export function readCompletedImageView(notification: RpcNotification): UiMessage | null {
+ if (notification.method !== 'item/completed') return null
+ const params = asRecord(notification.params)
+ const item = asRecord(params?.item)
+ if (!item) return null
+ const id = readString(item.id)
+ if (!id) return null
+ if (item.type === 'imageView') {
+ const path = readString(item.path)
+ if (!path) return null
+ return {
+ id,
+ role: 'assistant',
+ text: '',
+ images: [toLocalImageUrl(path)],
+ messageType: 'imageView',
+ }
+ }
+ if (item.type !== 'imageGeneration' && item.type !== 'image_generation') return null
+ const result = readString(item.result)
+ const imageUrl = result ? toImageGenerationUrl(result) : ''
+ if (!imageUrl) return null
+ return {
+ id,
+ role: 'assistant',
+ text: '',
+ images: [imageUrl],
+ messageType: 'imageView',
+
+ }
+}
+
+
+export function readCommandOutputDelta(notification: RpcNotification): { itemId: string; delta: string } | null {
+ if (notification.method !== 'item/commandExecution/outputDelta') return null
+ const params = asRecord(notification.params)
+ if (!params) return null
+ const itemId = readString(params.itemId)
+ const delta = readString(params.delta)
+ if (!itemId || !delta) return null
+ return { itemId, delta }
+}
+
+
+export function isAgentContentEvent(notification: RpcNotification): boolean {
+ if (notification.method === 'item/agentMessage/delta') {
+ return true
+ }
+
+ const params = asRecord(notification.params)
+ if (!params) return false
+
+ if (notification.method === 'item/completed') {
+ const item = asRecord(params.item)
+ return item?.type === 'agentMessage'
+ }
+
+ return false
+}
+
+
diff --git a/src/composables/desktopStateStorage.ts b/src/composables/desktopStateStorage.ts
new file mode 100644
index 000000000..c52e2e30c
--- /dev/null
+++ b/src/composables/desktopStateStorage.ts
@@ -0,0 +1,448 @@
+import type { CollaborationModeKind, UiThreadTokenUsage } from '../types/codex'
+import { toProjectName } from '../pathUtils.js'
+
+const READ_STATE_STORAGE_KEY = 'codex-web-local.thread-read-state.v1'
+const UNREAD_CUTOFF_STORAGE_KEY = 'codex-web-local.thread-unread-cutoff.v1'
+const THREAD_TOKEN_USAGE_STORAGE_KEY = 'codex-web-local.thread-token-usage.v1'
+const THREAD_TERMINAL_OPEN_STORAGE_KEY = 'codex-web-local.thread-terminal-open.v1'
+const SELECTED_THREAD_STORAGE_KEY = 'codex-web-local.selected-thread-id.v1'
+const SELECTED_MODEL_BY_CONTEXT_STORAGE_KEY = 'codex-web-local.selected-model-by-context.v1'
+const LEGACY_SELECTED_MODEL_STORAGE_KEY = 'codex-web-local.selected-model-id.v1'
+const PROJECT_ORDER_STORAGE_KEY = 'codex-web-local.project-order.v1'
+const PROJECT_DISPLAY_NAME_STORAGE_KEY = 'codex-web-local.project-display-name.v1'
+const COLLABORATION_MODE_STORAGE_KEY = 'codex-web-local.collaboration-mode-by-context.v1'
+const LEGACY_COLLABORATION_MODE_STORAGE_KEY = 'codex-web-local.collaboration-mode.v1'
+export const NEW_THREAD_COLLABORATION_MODE_CONTEXT = '__new-thread__'
+const NEW_THREAD_PROVIDER_MODEL_CONTEXT_PREFIX = '__new-thread-provider__::'
+
+export function loadReadStateMap(): Record {
+ if (typeof window === 'undefined') return {}
+
+ try {
+ const raw = window.localStorage.getItem(READ_STATE_STORAGE_KEY)
+ if (!raw) return {}
+
+ const parsed = JSON.parse(raw) as unknown
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
+ return parsed as Record
+ } catch {
+ return {}
+ }
+}
+
+export function saveReadStateMap(state: Record): void {
+ if (typeof window === 'undefined') return
+ window.localStorage.setItem(READ_STATE_STORAGE_KEY, JSON.stringify(state))
+}
+
+export function loadUnreadCutoffIso(): string {
+ if (typeof window === 'undefined') return ''
+
+ const existing = window.localStorage.getItem(UNREAD_CUTOFF_STORAGE_KEY)
+ if (existing) return existing
+
+ const initialCutoff = new Date().toISOString()
+ window.localStorage.setItem(UNREAD_CUTOFF_STORAGE_KEY, initialCutoff)
+ return initialCutoff
+}
+
+export function saveUnreadCutoffIso(cutoffIso: string): void {
+ if (typeof window === 'undefined') return
+ window.localStorage.setItem(UNREAD_CUTOFF_STORAGE_KEY, cutoffIso)
+}
+
+export function isThreadUpdatedAfterCutoff(updatedAtIso: string, cutoffIso: string): boolean {
+ if (!updatedAtIso || !cutoffIso) return false
+ const updatedAtMs = new Date(updatedAtIso).getTime()
+ const cutoffMs = new Date(cutoffIso).getTime()
+ if (!Number.isFinite(updatedAtMs) || !Number.isFinite(cutoffMs)) return false
+ return updatedAtMs > cutoffMs
+}
+
+export function isThreadUnreadByLastRead(
+ updatedAtIso: string,
+ threadReadStateIso: string | undefined,
+ unreadCutoffIso: string,
+): boolean {
+ const effectiveLastReadIso = threadReadStateIso ?? unreadCutoffIso
+ return isThreadUpdatedAfterCutoff(updatedAtIso, effectiveLastReadIso)
+}
+
+export function normalizeCollaborationMode(value: unknown): CollaborationModeKind {
+ return value === 'plan' ? 'plan' : 'default'
+}
+
+export function normalizeStoredModelId(value: unknown): string {
+ return typeof value === 'string' ? value.trim() : ''
+}
+
+export function createStringKeyedRecord(): Record {
+ return Object.create(null) as Record
+}
+
+export function cloneStringKeyedRecord(record: Record): Record {
+ const next = createStringKeyedRecord()
+ for (const [key, value] of Object.entries(record)) {
+ next[key] = value
+ }
+ return next
+}
+
+export function omitStringKeyedRecordKey(record: Record, key: string): Record {
+ if (!(key in record)) return record
+ const next = createStringKeyedRecord()
+ for (const [entryKey, value] of Object.entries(record)) {
+ if (entryKey !== key) {
+ next[entryKey] = value
+ }
+ }
+ return next
+}
+
+export function pruneThreadContextStateMap(
+ stateMap: Record,
+ threadIds: Set,
+): Record {
+ let changed = false
+ const next = createStringKeyedRecord()
+ for (const [contextId, value] of Object.entries(stateMap)) {
+ if (
+ contextId === NEW_THREAD_COLLABORATION_MODE_CONTEXT
+ || contextId.startsWith(NEW_THREAD_PROVIDER_MODEL_CONTEXT_PREFIX)
+ || threadIds.has(contextId)
+ ) {
+ next[contextId] = value
+ continue
+ }
+ changed = true
+ }
+ return changed ? next : stateMap
+}
+
+export function normalizeProviderContextId(providerId: string): string {
+ const normalized = providerId.trim().toLowerCase()
+ return normalized || 'codex'
+}
+
+export function isNewThreadContextId(contextId: string): boolean {
+ return contextId === NEW_THREAD_COLLABORATION_MODE_CONTEXT
+}
+
+export function toProviderModelContextId(providerId: string): string {
+ const normalizedProviderId = normalizeProviderContextId(providerId)
+ if (!normalizedProviderId) return ''
+ return `${NEW_THREAD_PROVIDER_MODEL_CONTEXT_PREFIX}${normalizedProviderId}`
+}
+
+export function toThreadContextId(threadId: string): string {
+ const normalizedThreadId = threadId.trim()
+ return normalizedThreadId || NEW_THREAD_COLLABORATION_MODE_CONTEXT
+}
+
+export function loadSelectedModelMap(): Record {
+ if (typeof window === 'undefined') return createStringKeyedRecord()
+
+ try {
+ const raw = window.localStorage.getItem(SELECTED_MODEL_BY_CONTEXT_STORAGE_KEY)
+ if (raw) {
+ const parsed = JSON.parse(raw) as unknown
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return createStringKeyedRecord()
+
+ const next = createStringKeyedRecord()
+ for (const [contextId, value] of Object.entries(parsed as Record)) {
+ if (typeof contextId !== 'string' || contextId.length === 0) continue
+ const normalizedModelId = normalizeStoredModelId(value)
+ if (normalizedModelId) {
+ next[contextId] = normalizedModelId
+ }
+ }
+ return next
+ }
+ } catch {
+ // Fall back to the legacy global preference below.
+ }
+
+ const legacyModelId = normalizeStoredModelId(window.localStorage.getItem(LEGACY_SELECTED_MODEL_STORAGE_KEY))
+ const next = createStringKeyedRecord()
+ if (legacyModelId) {
+ next[NEW_THREAD_COLLABORATION_MODE_CONTEXT] = legacyModelId
+ }
+ return next
+}
+
+export function readSelectedModel(
+ state: Record,
+ threadId: string,
+): string {
+ const contextId = toThreadContextId(threadId)
+ const contextModelId = normalizeStoredModelId(state[contextId])
+ if (contextModelId) return contextModelId
+ return normalizeStoredModelId(state[NEW_THREAD_COLLABORATION_MODE_CONTEXT])
+}
+
+export function saveSelectedModelMap(state: Record): void {
+ if (typeof window === 'undefined') return
+ try {
+ if (Object.keys(state).length === 0) {
+ window.localStorage.removeItem(SELECTED_MODEL_BY_CONTEXT_STORAGE_KEY)
+ } else {
+ window.localStorage.setItem(SELECTED_MODEL_BY_CONTEXT_STORAGE_KEY, JSON.stringify(state))
+ }
+ window.localStorage.removeItem(LEGACY_SELECTED_MODEL_STORAGE_KEY)
+ } catch {
+ // Keep in-memory selection working even if localStorage writes fail.
+ }
+}
+
+export function loadSelectedCollaborationModeMap(): Record {
+ if (typeof window === 'undefined') return createStringKeyedRecord()
+
+ try {
+ const raw = window.localStorage.getItem(COLLABORATION_MODE_STORAGE_KEY)
+ if (raw) {
+ const parsed = JSON.parse(raw) as unknown
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return createStringKeyedRecord()
+ }
+
+ const next = createStringKeyedRecord()
+ for (const [contextId, value] of Object.entries(parsed as Record)) {
+ if (typeof contextId !== 'string' || contextId.length === 0) continue
+ const normalizedMode = normalizeCollaborationMode(value)
+ if (normalizedMode === 'plan') {
+ next[contextId] = normalizedMode
+ }
+ }
+ return next
+ }
+ } catch {
+ // Fall back to the legacy global preference below.
+ }
+
+ return createStringKeyedRecord()
+}
+
+export function readSelectedCollaborationMode(
+ state: Record,
+ threadId: string,
+): CollaborationModeKind {
+ const contextId = toThreadContextId(threadId)
+ return normalizeCollaborationMode(state[contextId])
+}
+
+export function writeSelectedCollaborationModeForContext(
+ state: Record,
+ threadId: string,
+ mode: CollaborationModeKind,
+): Record {
+ const contextId = toThreadContextId(threadId)
+ if (isNewThreadContextId(contextId)) {
+ return omitStringKeyedRecordKey(state, contextId)
+ }
+ if (mode === 'plan') {
+ const next = cloneStringKeyedRecord(state)
+ next[contextId] = 'plan'
+ return next
+ }
+ return omitStringKeyedRecordKey(state, contextId)
+}
+
+export function saveSelectedCollaborationModeMap(state: Record): void {
+ if (typeof window === 'undefined') return
+ try {
+ if (Object.keys(state).length === 0) {
+ window.localStorage.removeItem(COLLABORATION_MODE_STORAGE_KEY)
+ } else {
+ window.localStorage.setItem(COLLABORATION_MODE_STORAGE_KEY, JSON.stringify(state))
+ }
+ window.localStorage.removeItem(LEGACY_COLLABORATION_MODE_STORAGE_KEY)
+ } catch {
+ // Keep in-memory mode selection working even if localStorage writes fail.
+ }
+}
+
+export function clamp(value: number, minValue: number, maxValue: number): number {
+ return Math.min(Math.max(value, minValue), maxValue)
+}
+
+export function normalizeStoredTokenCount(value: unknown): number | null {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return Math.max(0, Math.trunc(value))
+ }
+
+ if (typeof value === 'string' && value.trim().length > 0) {
+ const parsed = Number(value)
+ if (Number.isFinite(parsed)) {
+ return Math.max(0, Math.trunc(parsed))
+ }
+ }
+
+ return null
+}
+
+export function normalizeTokenUsageBreakdown(value: unknown): UiThreadTokenUsage['last'] | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
+
+ const record = value as Record
+ return {
+ totalTokens: normalizeStoredTokenCount(record.totalTokens) ?? 0,
+ inputTokens: normalizeStoredTokenCount(record.inputTokens) ?? 0,
+ cachedInputTokens: normalizeStoredTokenCount(record.cachedInputTokens) ?? 0,
+ outputTokens: normalizeStoredTokenCount(record.outputTokens) ?? 0,
+ reasoningOutputTokens: normalizeStoredTokenCount(record.reasoningOutputTokens) ?? 0,
+ }
+}
+
+export function normalizeThreadTokenUsage(value: unknown): UiThreadTokenUsage | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
+
+ const record = value as Record
+ const total = normalizeTokenUsageBreakdown(record.total)
+ const last = normalizeTokenUsageBreakdown(record.last)
+ if (!total || !last) return null
+
+ const modelContextWindow = normalizeStoredTokenCount(record.modelContextWindow)
+ const currentContextTokens = last.totalTokens
+ const remainingContextTokens = typeof modelContextWindow === 'number'
+ ? Math.max(modelContextWindow - currentContextTokens, 0)
+ : null
+ const remainingContextPercent = typeof modelContextWindow === 'number' && modelContextWindow > 0
+ ? clamp(Math.round((remainingContextTokens ?? 0) / modelContextWindow * 100), 0, 100)
+ : null
+
+ return {
+ total,
+ last,
+ modelContextWindow,
+ currentContextTokens,
+ remainingContextTokens,
+ remainingContextPercent,
+ }
+}
+
+export function loadThreadTokenUsageMap(): Record {
+ if (typeof window === 'undefined') return {}
+
+ try {
+ const raw = window.localStorage.getItem(THREAD_TOKEN_USAGE_STORAGE_KEY)
+ if (!raw) return {}
+
+ const parsed = JSON.parse(raw) as unknown
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
+
+ const normalizedMap: Record = {}
+ for (const [threadId, usage] of Object.entries(parsed as Record)) {
+ if (!threadId) continue
+ const normalizedUsage = normalizeThreadTokenUsage(usage)
+ if (normalizedUsage) {
+ normalizedMap[threadId] = normalizedUsage
+ }
+ }
+ return normalizedMap
+ } catch {
+ return {}
+ }
+}
+
+export function saveThreadTokenUsageMap(state: Record): void {
+ if (typeof window === 'undefined') return
+ window.localStorage.setItem(THREAD_TOKEN_USAGE_STORAGE_KEY, JSON.stringify(state))
+}
+
+export function loadThreadTerminalOpenMap(): Record {
+ if (typeof window === 'undefined') return {}
+
+ try {
+ const raw = window.localStorage.getItem(THREAD_TERMINAL_OPEN_STORAGE_KEY)
+ if (!raw) return {}
+
+ const parsed = JSON.parse(raw) as unknown
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
+
+ const normalizedMap: Record = {}
+ for (const [threadId, isOpen] of Object.entries(parsed as Record)) {
+ if (threadId && typeof isOpen === 'boolean') {
+ normalizedMap[threadId] = isOpen
+ }
+ }
+ return normalizedMap
+ } catch {
+ return {}
+ }
+}
+
+export function saveThreadTerminalOpenMap(state: Record): void {
+ if (typeof window === 'undefined') return
+ window.localStorage.setItem(THREAD_TERMINAL_OPEN_STORAGE_KEY, JSON.stringify(state))
+}
+
+export function loadSelectedThreadId(): string {
+ if (typeof window === 'undefined') return ''
+ const raw = window.localStorage.getItem(SELECTED_THREAD_STORAGE_KEY)
+ return raw ?? ''
+}
+
+export function saveSelectedThreadId(threadId: string): void {
+ if (typeof window === 'undefined') return
+ if (!threadId) {
+ window.localStorage.removeItem(SELECTED_THREAD_STORAGE_KEY)
+ return
+ }
+ window.localStorage.setItem(SELECTED_THREAD_STORAGE_KEY, threadId)
+}
+
+export function loadProjectOrder(): string[] {
+ if (typeof window === 'undefined') return []
+
+ try {
+ const raw = window.localStorage.getItem(PROJECT_ORDER_STORAGE_KEY)
+ if (!raw) return []
+
+ const parsed = JSON.parse(raw) as unknown
+ if (!Array.isArray(parsed)) return []
+ const order: string[] = []
+ for (const item of parsed) {
+ if (typeof item !== 'string' || item.length === 0) continue
+ const normalizedItem = toProjectName(item)
+ if (normalizedItem.length > 0 && !order.includes(normalizedItem)) {
+ order.push(normalizedItem)
+ }
+ }
+ return order
+ } catch {
+ return []
+ }
+}
+
+export function saveProjectOrder(order: string[]): void {
+ if (typeof window === 'undefined') return
+ window.localStorage.setItem(PROJECT_ORDER_STORAGE_KEY, JSON.stringify(order))
+}
+
+export function loadProjectDisplayNames(): Record {
+ if (typeof window === 'undefined') return {}
+
+ try {
+ const raw = window.localStorage.getItem(PROJECT_DISPLAY_NAME_STORAGE_KEY)
+ if (!raw) return {}
+
+ const parsed = JSON.parse(raw) as unknown
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
+
+ const displayNames: Record = {}
+ for (const [projectName, displayName] of Object.entries(parsed as Record)) {
+ const normalizedProjectName = typeof projectName === 'string' ? toProjectName(projectName) : ''
+ if (normalizedProjectName.length > 0 && typeof displayName === 'string') {
+ displayNames[normalizedProjectName] = displayName
+ }
+ }
+ return displayNames
+ } catch {
+ return {}
+ }
+}
+
+export function saveProjectDisplayNames(displayNames: Record): void {
+ if (typeof window === 'undefined') return
+ window.localStorage.setItem(PROJECT_DISPLAY_NAME_STORAGE_KEY, JSON.stringify(displayNames))
+}
diff --git a/src/composables/desktopStateThreadHelpers.ts b/src/composables/desktopStateThreadHelpers.ts
new file mode 100644
index 000000000..958981312
--- /dev/null
+++ b/src/composables/desktopStateThreadHelpers.ts
@@ -0,0 +1,846 @@
+import type {
+ CommandExecutionData,
+ UiFileChange,
+ UiMessage,
+ UiPlanData,
+ UiPlanStep,
+ UiProjectGroup,
+ UiThread,
+ ReasoningEffort,
+} from '../types/codex'
+import type { WorkspaceRootsState } from '../api/codexGateway'
+import { getPathParent, isProjectlessChatPath, normalizePathForUi, toProjectName } from '../pathUtils.js'
+
+export function flattenThreads(groups: UiProjectGroup[]): UiThread[] {
+ return groups.flatMap((group) => group.threads)
+}
+
+export function findAdjacentThreadId(threads: UiThread[], threadId: string): string {
+ const targetIndex = threads.findIndex((thread) => thread.id === threadId)
+ if (targetIndex < 0) return ''
+ return threads[targetIndex + 1]?.id ?? threads[targetIndex - 1]?.id ?? ''
+}
+
+export const EVENT_SYNC_DEBOUNCE_MS = 220
+export const BACKGROUND_THREAD_PAGINATION_DELAY_MS = 10_000
+export const RATE_LIMIT_REFRESH_DEBOUNCE_MS = 500
+export const TURN_START_FOLLOW_UP_SYNC_DELAY_MS = 3000
+export const RECENT_THREAD_MESSAGE_LOAD_REUSE_MS = 2000
+export const REASONING_EFFORT_OPTIONS: ReasoningEffort[] = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
+export const GLOBAL_SERVER_REQUEST_SCOPE = '__global__'
+export const MODEL_FALLBACK_ID = 'gpt-5.4-mini'
+
+export function mergeProjectOrder(previousOrder: string[], incomingGroups: UiProjectGroup[]): string[] {
+ const nextOrder: string[] = []
+
+ for (const projectName of previousOrder) {
+ if (!nextOrder.includes(projectName)) {
+ nextOrder.push(projectName)
+ }
+ }
+
+ for (const group of incomingGroups) {
+ if (!nextOrder.includes(group.projectName)) {
+ nextOrder.push(group.projectName)
+ }
+ }
+
+ return areStringArraysEqual(previousOrder, nextOrder) ? previousOrder : nextOrder
+}
+
+export function orderGroupsByProjectOrder(incoming: UiProjectGroup[], projectOrder: string[]): UiProjectGroup[] {
+ const incomingByName = new Map(incoming.map((group) => [group.projectName, group]))
+ const ordered: UiProjectGroup[] = projectOrder
+ .map((projectName) => incomingByName.get(projectName) ?? null)
+ .filter((group): group is UiProjectGroup => group !== null)
+
+ for (const group of incoming) {
+ if (!projectOrder.includes(group.projectName)) {
+ ordered.push(group)
+ }
+ }
+
+ return ordered
+}
+
+export function areStringArraysEqual(first?: string[], second?: string[]): boolean {
+ const left = Array.isArray(first) ? first : []
+ const right = Array.isArray(second) ? second : []
+ if (left.length !== right.length) return false
+ for (let index = 0; index < left.length; index += 1) {
+ if (left[index] !== right[index]) return false
+ }
+ return true
+}
+
+export function reorderStringArray(items: string[], fromIndex: number, toIndex: number): string[] {
+ if (fromIndex < 0 || fromIndex >= items.length || toIndex < 0 || toIndex >= items.length) {
+ return items
+ }
+
+ if (fromIndex === toIndex) {
+ return items
+ }
+
+ const next = [...items]
+ const [moved] = next.splice(fromIndex, 1)
+ next.splice(toIndex, 0, moved)
+ return next
+}
+
+export function areCommandExecutionsEqual(first?: CommandExecutionData, second?: CommandExecutionData): boolean {
+ if (!first && !second) return true
+ if (!first || !second) return false
+ return first.status === second.status && first.aggregatedOutput === second.aggregatedOutput && first.exitCode === second.exitCode
+}
+
+export function arePlanStepsEqual(first: UiPlanStep[] = [], second: UiPlanStep[] = []): boolean {
+ if (first.length !== second.length) return false
+ for (let index = 0; index < first.length; index += 1) {
+ if (first[index]?.step !== second[index]?.step || first[index]?.status !== second[index]?.status) {
+ return false
+ }
+ }
+ return true
+}
+
+export function arePlanDataEqual(first?: UiPlanData, second?: UiPlanData): boolean {
+ if (!first && !second) return true
+ if (!first || !second) return false
+ return (
+ first.explanation === second.explanation &&
+ first.isStreaming === second.isStreaming &&
+ arePlanStepsEqual(first.steps, second.steps)
+ )
+}
+
+export function isUnsupportedChatGptModelError(error: unknown): boolean {
+ if (!(error instanceof Error)) return false
+ const message = error.message.toLowerCase()
+ return (
+ message.includes('not supported when using codex with a chatgpt account') ||
+ message.includes('model is not supported') ||
+ message.includes('requires a newer version of codex')
+ )
+}
+
+export function areMessageFieldsEqual(first: UiMessage, second: UiMessage): boolean {
+ return (
+ first.id === second.id &&
+ first.role === second.role &&
+ first.text === second.text &&
+ areStringArraysEqual(first.images, second.images) &&
+ areUiFileChangesEqual(first.fileChanges, second.fileChanges) &&
+ first.fileChangeStatus === second.fileChangeStatus &&
+ first.messageType === second.messageType &&
+ first.rawPayload === second.rawPayload &&
+ first.isUnhandled === second.isUnhandled &&
+ areCommandExecutionsEqual(first.commandExecution, second.commandExecution) &&
+ arePlanDataEqual(first.plan, second.plan) &&
+ first.turnId === second.turnId &&
+ first.turnIndex === second.turnIndex &&
+ first.isAutomationRun === second.isAutomationRun &&
+ first.automationDisplayName === second.automationDisplayName
+ )
+}
+
+export function areMessageArraysEqual(first: UiMessage[], second: UiMessage[]): boolean {
+ if (first.length !== second.length) return false
+ for (let index = 0; index < first.length; index += 1) {
+ if (first[index] !== second[index]) return false
+ }
+ return true
+}
+
+export function mergeMessages(
+ previous: UiMessage[],
+ incoming: UiMessage[],
+ options: { preserveMissing?: boolean } = {},
+): UiMessage[] {
+ const previousById = new Map(previous.map((message) => [message.id, message]))
+ const incomingById = new Map(incoming.map((message) => [message.id, message]))
+
+ const mergedIncoming = incoming.map((incomingMessage) => {
+ const previousMessage = previousById.get(incomingMessage.id)
+ if (previousMessage && areMessageFieldsEqual(previousMessage, incomingMessage)) {
+ return previousMessage
+ }
+ return incomingMessage
+ })
+
+ if (options.preserveMissing !== true) {
+ return areMessageArraysEqual(previous, mergedIncoming) ? previous : mergedIncoming
+ }
+
+ const mergedFromPrevious = previous.map((previousMessage) => {
+ const nextMessage = incomingById.get(previousMessage.id)
+ if (!nextMessage) {
+ return previousMessage
+ }
+ if (areMessageFieldsEqual(previousMessage, nextMessage)) {
+ return previousMessage
+ }
+ return nextMessage
+ })
+
+ const previousIdSet = new Set(previous.map((message) => message.id))
+ const appended = mergedIncoming.filter((message) => !previousIdSet.has(message.id))
+ const merged = [...mergedFromPrevious, ...appended]
+
+ return areMessageArraysEqual(previous, merged) ? previous : merged
+}
+
+export function areUiFileChangesEqual(first?: UiFileChange[], second?: UiFileChange[]): boolean {
+ if (!first && !second) return true
+ if (!first || !second) return false
+ if (first.length !== second.length) return false
+ for (let index = 0; index < first.length; index += 1) {
+ const firstChange = first[index]
+ const secondChange = second[index]
+ if (
+ firstChange.path !== secondChange.path ||
+ firstChange.operation !== secondChange.operation ||
+ firstChange.movedToPath !== secondChange.movedToPath ||
+ firstChange.diff !== secondChange.diff ||
+ firstChange.addedLineCount !== secondChange.addedLineCount ||
+ firstChange.removedLineCount !== secondChange.removedLineCount
+ ) {
+ return false
+ }
+ }
+ return true
+}
+
+export function normalizeMessageText(value: string): string {
+ return value.replace(/\s+/gu, ' ').trim()
+}
+
+export function removeRedundantLiveAgentMessages(previous: UiMessage[], incoming: UiMessage[]): UiMessage[] {
+ const incomingMessageIds = new Set(incoming.map((message) => message.id))
+ const incomingAssistantTexts = new Set(
+ incoming
+ .filter((message) => message.role === 'assistant')
+ .map((message) => normalizeMessageText(message.text))
+ .filter((text) => text.length > 0),
+ )
+
+ if (incomingAssistantTexts.size === 0) {
+ return previous
+ }
+
+ const next = previous.filter((message) => {
+ if (message.messageType !== 'agentMessage.live') return true
+ if (incomingMessageIds.has(message.id)) return false
+ const normalized = normalizeMessageText(message.text)
+ if (normalized.length === 0) return false
+ return !incomingAssistantTexts.has(normalized)
+ })
+
+ return next.length === previous.length ? previous : next
+}
+
+export function removePersistedLiveMessages(previous: UiMessage[], incoming: UiMessage[]): UiMessage[] {
+ const incomingIds = new Set(incoming.map((message) => message.id))
+ const next = previous.filter((message) => !incomingIds.has(message.id))
+ return next.length === previous.length ? previous : next
+}
+
+export function upsertMessage(previous: UiMessage[], nextMessage: UiMessage): UiMessage[] {
+ const existingIndex = previous.findIndex((message) => message.id === nextMessage.id)
+ if (existingIndex < 0) {
+ return [...previous, nextMessage]
+ }
+
+ const existing = previous[existingIndex]
+ if (areMessageFieldsEqual(existing, nextMessage)) {
+ return previous
+ }
+
+ const next = [...previous]
+ next.splice(existingIndex, 1, nextMessage)
+ return next
+}
+
+export type TurnSummaryState = {
+ turnId: string
+ durationMs: number
+}
+
+export type TurnActivityState = {
+ label: string
+ details: string[]
+}
+
+export type TurnErrorState = {
+ message: string
+ transient: boolean
+}
+
+export type TurnStartedInfo = {
+ threadId: string
+ turnId: string
+ startedAtMs: number
+}
+
+export type TurnCompletedInfo = {
+ threadId: string
+ turnId: string
+ completedAtMs: number
+ startedAtMs?: number
+}
+
+export const WORKED_MESSAGE_TYPE = 'worked'
+
+export function parseIsoTimestamp(value: string): number | null {
+ if (!value) return null
+ const ms = new Date(value).getTime()
+ return Number.isNaN(ms) ? null : ms
+}
+
+export function formatTurnDuration(durationMs: number): string {
+ if (!Number.isFinite(durationMs) || durationMs <= 0) {
+ return '<1s'
+ }
+
+ const totalSeconds = Math.max(1, Math.round(durationMs / 1000))
+ const hours = Math.floor(totalSeconds / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+ const seconds = totalSeconds % 60
+ const parts: string[] = []
+
+ if (hours > 0) {
+ parts.push(`${hours}h`)
+ }
+
+ if (minutes > 0 || hours > 0) {
+ parts.push(`${minutes}m`)
+ }
+
+ const displaySeconds = seconds > 0 || parts.length === 0 ? seconds : 0
+ parts.push(`${displaySeconds}s`)
+ return parts.join(' ')
+}
+
+export function areTurnSummariesEqual(first?: TurnSummaryState, second?: TurnSummaryState): boolean {
+ if (!first && !second) return true
+ if (!first || !second) return false
+ return first.turnId === second.turnId && first.durationMs === second.durationMs
+}
+
+export function areTurnActivitiesEqual(first?: TurnActivityState, second?: TurnActivityState): boolean {
+ if (!first && !second) return true
+ if (!first || !second) return false
+ if (first.label !== second.label) return false
+ if (first.details.length !== second.details.length) return false
+ for (let index = 0; index < first.details.length; index += 1) {
+ if (first.details[index] !== second.details[index]) return false
+ }
+ return true
+}
+
+export function buildTurnSummaryMessage(summary: TurnSummaryState): UiMessage {
+ return {
+ id: `turn-summary:${summary.turnId}`,
+ role: 'system',
+ text: `Worked for ${formatTurnDuration(summary.durationMs)}`,
+ messageType: WORKED_MESSAGE_TYPE,
+ turnId: summary.turnId,
+ }
+}
+
+export function findLastAssistantMessageIndex(messages: UiMessage[]): number {
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ if (messages[index].role === 'assistant') {
+ return index
+ }
+ }
+ return -1
+}
+
+export function insertTurnSummaryMessage(messages: UiMessage[], summary: TurnSummaryState): UiMessage[] {
+ const summaryMessage = buildTurnSummaryMessage(summary)
+ const sanitizedMessages = messages.filter((message) => message.messageType !== WORKED_MESSAGE_TYPE)
+ const insertIndex = findLastAssistantMessageIndex(sanitizedMessages)
+ if (insertIndex < 0) {
+ return [...sanitizedMessages, summaryMessage]
+ }
+ const next = [...sanitizedMessages]
+ next.splice(insertIndex, 0, summaryMessage)
+ return next
+}
+
+export function omitKey(record: Record, key: string): Record {
+ if (!(key in record)) return record
+ const next = { ...record }
+ delete next[key]
+ return next
+}
+
+export function omitKeys(record: Record, keys: Set): Record {
+ if (keys.size === 0) return record
+ let changed = false
+ const next: Record = {}
+ for (const [key, value] of Object.entries(record)) {
+ if (keys.has(key)) {
+ changed = true
+ continue
+ }
+ next[key] = value
+ }
+ return changed ? next : record
+}
+
+export function areThreadFieldsEqual(first: UiThread, second: UiThread): boolean {
+ return (
+ first.id === second.id &&
+ first.title === second.title &&
+ first.projectName === second.projectName &&
+ first.cwd === second.cwd &&
+ first.createdAtIso === second.createdAtIso &&
+ first.updatedAtIso === second.updatedAtIso &&
+ first.preview === second.preview &&
+ first.unread === second.unread &&
+ first.inProgress === second.inProgress &&
+ first.pendingRequestState === second.pendingRequestState
+ )
+}
+
+export function areThreadArraysEqual(first: UiThread[], second: UiThread[]): boolean {
+ if (first.length !== second.length) return false
+ for (let index = 0; index < first.length; index += 1) {
+ if (first[index] !== second[index]) return false
+ }
+ return true
+}
+
+export function areGroupArraysEqual(first: UiProjectGroup[], second: UiProjectGroup[]): boolean {
+ if (first.length !== second.length) return false
+ for (let index = 0; index < first.length; index += 1) {
+ if (first[index] !== second[index]) return false
+ }
+ return true
+}
+
+export function pruneThreadStateMap(stateMap: Record, threadIds: Set): Record {
+ const nextEntries = Object.entries(stateMap).filter(([threadId]) => threadIds.has(threadId))
+ if (nextEntries.length === Object.keys(stateMap).length) {
+ return stateMap
+ }
+ return Object.fromEntries(nextEntries) as Record
+}
+
+export function removeThreadFromGroups(groups: UiProjectGroup[], threadId: string): UiProjectGroup[] {
+ const normalizedThreadId = threadId.trim()
+ if (!normalizedThreadId) return groups
+
+ let changed = false
+ const nextGroups: UiProjectGroup[] = []
+
+ for (const group of groups) {
+ const nextThreads = group.threads.filter((thread) => thread.id !== normalizedThreadId)
+ const removedFromGroup = nextThreads.length !== group.threads.length
+ if (removedFromGroup) {
+ changed = true
+ }
+ if (nextThreads.length > 0) {
+ nextGroups.push(removedFromGroup ? { ...group, threads: nextThreads } : group)
+ } else if (group.threads.length === 0) {
+ nextGroups.push(group)
+ }
+ }
+
+ return changed ? nextGroups : groups
+}
+
+export function mergeThreadGroups(
+ previous: UiProjectGroup[],
+ incoming: UiProjectGroup[],
+): UiProjectGroup[] {
+ const previousGroupsByName = new Map(previous.map((group) => [group.projectName, group]))
+ const mergedGroups: UiProjectGroup[] = incoming.map((incomingGroup) => {
+ const previousGroup = previousGroupsByName.get(incomingGroup.projectName)
+ const previousThreadsById = new Map(previousGroup?.threads.map((thread) => [thread.id, thread]) ?? [])
+
+ const mergedThreads = incomingGroup.threads.map((incomingThread) => {
+ const previousThread = previousThreadsById.get(incomingThread.id)
+ if (previousThread && areThreadFieldsEqual(previousThread, incomingThread)) {
+ return previousThread
+ }
+ return incomingThread
+ })
+
+ if (
+ previousGroup &&
+ previousGroup.projectName === incomingGroup.projectName &&
+ areThreadArraysEqual(previousGroup.threads, mergedThreads)
+ ) {
+ return previousGroup
+ }
+
+ return {
+ projectName: incomingGroup.projectName,
+ threads: mergedThreads,
+ }
+ })
+
+ return areGroupArraysEqual(previous, mergedGroups) ? previous : mergedGroups
+}
+
+export function mergeIncomingWithLocalInProgressThreads(
+ previous: UiProjectGroup[],
+ incoming: UiProjectGroup[],
+ inProgressById: Record,
+): UiProjectGroup[] {
+ const incomingThreadIds = new Set(flattenThreads(incoming).map((thread) => thread.id))
+ const localInProgressThreads = flattenThreads(previous).filter(
+ (thread) => inProgressById[thread.id] === true && !incomingThreadIds.has(thread.id),
+ )
+
+ if (localInProgressThreads.length === 0) {
+ return incoming
+ }
+
+ const incomingByProjectName = new Map(incoming.map((group) => [group.projectName, group]))
+ const merged: UiProjectGroup[] = incoming.map((group) => ({
+ projectName: group.projectName,
+ threads: [...group.threads],
+ }))
+
+ for (const thread of localInProgressThreads) {
+ const existingGroup = incomingByProjectName.get(thread.projectName)
+ if (existingGroup) {
+ const mergedGroupIndex = merged.findIndex((group) => group.projectName === thread.projectName)
+ if (mergedGroupIndex >= 0) {
+ merged[mergedGroupIndex] = {
+ projectName: merged[mergedGroupIndex].projectName,
+ threads: [thread, ...merged[mergedGroupIndex].threads],
+ }
+ }
+ continue
+ }
+
+ merged.push({
+ projectName: thread.projectName,
+ threads: [thread],
+ })
+ }
+
+ return merged
+}
+
+export function toProjectNameFromWorkspaceRoot(value: string): string {
+ return toProjectName(value)
+}
+
+export function getRemoteProjectHostLabel(hostId: string): string {
+ const normalized = hostId.trim()
+ if (!normalized) return ''
+ const separatorIndex = normalized.lastIndexOf(':')
+ return separatorIndex >= 0 ? normalized.slice(separatorIndex + 1) : normalized
+}
+
+export function getRemoteProjectDisplayName(remoteProject: NonNullable[number]): string {
+ const label = remoteProject.label || toProjectName(remoteProject.remotePath) || remoteProject.id
+ const hostLabel = getRemoteProjectHostLabel(remoteProject.hostId)
+ return hostLabel ? `${label} ${hostLabel}` : label
+}
+
+export function getRemoteProjectById(rootsState: WorkspaceRootsState | null): Map[number]> {
+ const remoteProjects = rootsState?.remoteProjects ?? []
+ return new Map(remoteProjects.map((project) => [project.id, project]))
+}
+
+export function getWorkspaceProjectOrderPaths(rootsState: WorkspaceRootsState | null): string[] {
+ if (!rootsState) return []
+ const savedRoots = new Set(rootsState.order)
+ const remoteProjectIds = new Set((rootsState.remoteProjects ?? []).map((project) => project.id))
+ const orderedRoots = rootsState.projectOrder.filter((item) => savedRoots.has(item) || remoteProjectIds.has(item))
+ for (const rootPath of rootsState.order) {
+ if (!orderedRoots.includes(rootPath)) orderedRoots.push(rootPath)
+ }
+ for (const remoteProjectId of remoteProjectIds) {
+ if (!orderedRoots.includes(remoteProjectId)) orderedRoots.push(remoteProjectId)
+ }
+ return orderedRoots
+}
+
+export function getWorkspaceProjectOrderNames(
+ rootsState: WorkspaceRootsState | null,
+ duplicateLeafNames: Set,
+): string[] {
+ const remoteProjectsById = getRemoteProjectById(rootsState)
+ return getWorkspaceProjectOrderPaths(rootsState).map((rootPath) => {
+ if (remoteProjectsById.has(rootPath)) return rootPath
+ const normalizedRootPath = normalizePathForUi(rootPath).trim()
+ const leafName = toProjectNameFromWorkspaceRoot(normalizedRootPath)
+ return duplicateLeafNames.has(leafName) ? normalizedRootPath : leafName
+ })
+}
+
+export function matchesWorkspaceRootProject(rootPath: string, projectName: string): boolean {
+ const normalizedRootPath = normalizePathForUi(rootPath).trim()
+ return normalizedRootPath === projectName || toProjectNameFromWorkspaceRoot(rootPath) === projectName
+}
+
+export function collectWorkspaceRootPathsForProjectRemoval(
+ rootsState: WorkspaceRootsState,
+ projectName: string,
+): Set {
+ const removedRootPaths = new Set()
+ for (const rootPath of rootsState.order) {
+ if (matchesWorkspaceRootProject(rootPath, projectName)) {
+ removedRootPaths.add(rootPath)
+ }
+ }
+ for (const rootPath of rootsState.active) {
+ if (matchesWorkspaceRootProject(rootPath, projectName)) {
+ removedRootPaths.add(rootPath)
+ }
+ }
+ for (const rootPath of Object.keys(rootsState.labels)) {
+ if (matchesWorkspaceRootProject(rootPath, projectName)) {
+ removedRootPaths.add(rootPath)
+ }
+ }
+ return removedRootPaths
+}
+
+export function buildWorkspaceRootsProjectOrderState(
+ rootsState: WorkspaceRootsState,
+ orderedProjectNames: string[],
+ groups: UiProjectGroup[],
+): Pick {
+ const remoteProjectIds = new Set((rootsState.remoteProjects ?? []).map((project) => project.id))
+ const rootByProjectName = new Map()
+ for (const rootPath of rootsState.order) {
+ const projectName = toProjectNameFromWorkspaceRoot(rootPath)
+ if (!rootByProjectName.has(projectName)) {
+ rootByProjectName.set(projectName, rootPath)
+ }
+ }
+ for (const group of groups) {
+ const cwd = group.threads[0]?.cwd?.trim() ?? ''
+ if (!cwd) continue
+ rootByProjectName.set(group.projectName, cwd)
+ }
+
+ const nextProjectOrder: string[] = []
+ const pushProjectOrderItem = (item: string): void => {
+ if (item && !nextProjectOrder.includes(item)) {
+ nextProjectOrder.push(item)
+ }
+ }
+
+ for (const projectName of orderedProjectNames) {
+ if (remoteProjectIds.has(projectName)) {
+ pushProjectOrderItem(projectName)
+ continue
+ }
+ const rootPath = rootByProjectName.get(projectName)
+ if (rootPath) {
+ pushProjectOrderItem(rootPath)
+ }
+ }
+ for (const item of getWorkspaceProjectOrderPaths(rootsState)) {
+ pushProjectOrderItem(item)
+ }
+
+ const nextOrder = nextProjectOrder.filter((item) => rootsState.order.includes(item))
+ for (const rootPath of rootsState.order) {
+ if (!nextOrder.includes(rootPath)) {
+ nextOrder.push(rootPath)
+ }
+ }
+
+ const nextActive = rootsState.active.filter((rootPath) => nextOrder.includes(rootPath))
+ if (nextActive.length === 0 && nextOrder.length > 0) {
+ nextActive.push(nextOrder[0])
+ }
+
+ return {
+ order: nextOrder,
+ active: nextActive,
+ projectOrder: nextProjectOrder,
+ }
+}
+
+export function orderGroupsByWorkspaceProjectOrder(
+ groups: UiProjectGroup[],
+ rootsState: WorkspaceRootsState | null,
+ duplicateLeafNames: Set,
+): UiProjectGroup[] {
+ const order = getWorkspaceProjectOrderNames(rootsState, duplicateLeafNames)
+ if (order.length === 0) return groups
+ const orderIndexByName = new Map(order.map((name, index) => [name, index]))
+ return [...groups].sort((first, second) => {
+ if (isProjectlessGroup(first) || isProjectlessGroup(second)) return 0
+ const firstIndex = orderIndexByName.get(first.projectName) ?? Number.POSITIVE_INFINITY
+ const secondIndex = orderIndexByName.get(second.projectName) ?? Number.POSITIVE_INFINITY
+ if (firstIndex === secondIndex) return 0
+ return firstIndex - secondIndex
+ })
+}
+
+export function collectDuplicateProjectLeafNames(groups: UiProjectGroup[], rootsState: WorkspaceRootsState | null): Set {
+ const rootByLeafName = new Map>()
+ const canonicalWorkspaceRootCountsByLeafName = new Map()
+ const addPath = (value: string): void => {
+ const normalizedPath = normalizePathForUi(value).trim()
+ if (!normalizedPath) return
+ const leafName = toProjectName(normalizedPath)
+ const existing = rootByLeafName.get(leafName) ?? new Set()
+ existing.add(normalizedPath)
+ rootByLeafName.set(leafName, existing)
+ }
+
+ for (const rootPath of rootsState?.order ?? []) {
+ const normalizedRootPath = normalizePathForUi(rootPath).trim()
+ if (!normalizedRootPath) continue
+ const leafName = toProjectName(normalizedRootPath)
+ if (!isManagedCodexWorktreePath(normalizedRootPath)) {
+ canonicalWorkspaceRootCountsByLeafName.set(leafName, (canonicalWorkspaceRootCountsByLeafName.get(leafName) ?? 0) + 1)
+ }
+ addPath(rootPath)
+ }
+ for (const group of groups) {
+ for (const thread of group.threads) {
+ const normalizedCwd = normalizePathForUi(thread.cwd).trim()
+ const leafName = toProjectName(normalizedCwd)
+ const isRegisteredRoot = rootsState?.order.some((rootPath) => normalizePathForUi(rootPath).trim() === normalizedCwd) === true
+ if (isManagedCodexWorktreePath(normalizedCwd) && !isRegisteredRoot && canonicalWorkspaceRootCountsByLeafName.get(leafName) === 1) continue
+ addPath(thread.cwd)
+ }
+ }
+
+ const duplicateLeafNames = new Set()
+ for (const [leafName, paths] of rootByLeafName.entries()) {
+ if (paths.size > 1) duplicateLeafNames.add(leafName)
+ }
+ return duplicateLeafNames
+}
+
+export function isManagedCodexWorktreePath(value: string): boolean {
+ return value.includes('/.codex/worktrees/')
+}
+
+export function disambiguateProjectGroupsByCwd(
+ groups: UiProjectGroup[],
+ rootsState: WorkspaceRootsState | null,
+): UiProjectGroup[] {
+ const duplicateLeafNames = collectDuplicateProjectLeafNames(groups, rootsState)
+ if (duplicateLeafNames.size === 0) return groups
+
+ const uniqueCanonicalWorkspaceRootLeafNames = new Set()
+ const duplicateCanonicalWorkspaceRootLeafNames = new Set()
+ const canonicalWorkspaceRootByLeafName = new Map()
+ const registeredWorkspaceRoots = new Set()
+ for (const rootPath of rootsState?.order ?? []) {
+ const normalizedRootPath = normalizePathForUi(rootPath).trim()
+ if (!normalizedRootPath) continue
+ registeredWorkspaceRoots.add(normalizedRootPath)
+ if (isManagedCodexWorktreePath(normalizedRootPath)) continue
+ const leafName = toProjectName(normalizedRootPath)
+ if (uniqueCanonicalWorkspaceRootLeafNames.has(leafName)) {
+ uniqueCanonicalWorkspaceRootLeafNames.delete(leafName)
+ duplicateCanonicalWorkspaceRootLeafNames.add(leafName)
+ canonicalWorkspaceRootByLeafName.delete(leafName)
+ } else if (!duplicateCanonicalWorkspaceRootLeafNames.has(leafName)) {
+ uniqueCanonicalWorkspaceRootLeafNames.add(leafName)
+ canonicalWorkspaceRootByLeafName.set(leafName, normalizedRootPath)
+ }
+ }
+
+ const disambiguatedGroups: UiProjectGroup[] = []
+ const groupsByProjectName = new Map()
+ for (const group of groups) {
+ for (const thread of group.threads) {
+ const normalizedCwd = normalizePathForUi(thread.cwd).trim()
+ const leafName = toProjectName(normalizedCwd)
+ const isRegisteredRoot = registeredWorkspaceRoots.has(normalizedCwd)
+ const isCanonicalWorktreeThread = isManagedCodexWorktreePath(normalizedCwd)
+ && !isRegisteredRoot
+ && uniqueCanonicalWorkspaceRootLeafNames.has(leafName)
+ let projectName = group.projectName
+ if (isCanonicalWorktreeThread && duplicateLeafNames.has(leafName)) {
+ projectName = canonicalWorkspaceRootByLeafName.get(leafName) ?? group.projectName
+ } else if (normalizedCwd && duplicateLeafNames.has(leafName)) {
+ projectName = normalizedCwd
+ }
+ const nextThread = thread.projectName === projectName ? thread : { ...thread, projectName }
+ const existingGroup = groupsByProjectName.get(projectName)
+ if (existingGroup) {
+ existingGroup.threads.push(nextThread)
+ } else {
+ const nextGroup = { projectName, threads: [nextThread] }
+ groupsByProjectName.set(projectName, nextGroup)
+ disambiguatedGroups.push(nextGroup)
+ }
+ }
+ }
+
+ return disambiguatedGroups
+}
+
+export function addWorkspaceRootPlaceholderGroups(
+ groups: UiProjectGroup[],
+ rootsState: WorkspaceRootsState | null,
+ duplicateLeafNames: Set,
+): UiProjectGroup[] {
+ if (!rootsState || (rootsState.order.length === 0 && (rootsState.remoteProjects ?? []).length === 0)) return groups
+ const existingProjectNames = new Set(groups.map((group) => group.projectName))
+ const nextGroups = [...groups]
+ const remoteProjectsById = getRemoteProjectById(rootsState)
+
+ for (const rootPath of getWorkspaceProjectOrderPaths(rootsState)) {
+ if (remoteProjectsById.has(rootPath)) {
+ if (existingProjectNames.has(rootPath)) continue
+ nextGroups.push({ projectName: rootPath, threads: [] })
+ existingProjectNames.add(rootPath)
+ continue
+ }
+ const normalizedRootPath = normalizePathForUi(rootPath).trim()
+ if (!normalizedRootPath) continue
+ const leafName = toProjectNameFromWorkspaceRoot(normalizedRootPath)
+ const projectName = duplicateLeafNames.has(leafName) ? normalizedRootPath : leafName
+ if (existingProjectNames.has(projectName)) continue
+ nextGroups.push({ projectName, threads: [] })
+ existingProjectNames.add(projectName)
+ }
+
+ return nextGroups
+}
+
+export function toOptimisticThreadTitle(message: string): string {
+ const firstLine = message
+ .split('\n')
+ .map((line) => line.trim())
+ .find((line) => line.length > 0)
+
+ if (!firstLine) return 'Untitled thread'
+ return firstLine.slice(0, 80)
+}
+
+export function toForkedThreadTitle(title: string): string {
+ const normalizedTitle = title.trim() || 'Untitled thread'
+ return /^fork:\s+/iu.test(normalizedTitle) ? normalizedTitle : `Fork: ${normalizedTitle}`
+}
+
+export function isProjectlessGroup(group: UiProjectGroup): boolean {
+ return group.threads.some((thread) => thread.cwd.trim().length === 0 || isProjectlessChatPath(thread.cwd))
+}
+
+export function filterGroupsByWorkspaceRoots(
+ groups: UiProjectGroup[],
+ rootsState: WorkspaceRootsState | null,
+): UiProjectGroup[] {
+ const duplicateLeafNames = collectDuplicateProjectLeafNames(groups, rootsState)
+ const disambiguatedGroups = disambiguateProjectGroupsByCwd(groups, rootsState)
+ const groupsWithWorkspaceRoots = addWorkspaceRootPlaceholderGroups(disambiguatedGroups, rootsState, duplicateLeafNames)
+ if (!rootsState || (rootsState.order.length === 0 && (rootsState.remoteProjects ?? []).length === 0)) return groupsWithWorkspaceRoots
+ const allowedProjectNames = new Set()
+ for (const projectName of getWorkspaceProjectOrderNames(rootsState, duplicateLeafNames)) {
+ allowedProjectNames.add(projectName)
+ }
+ const filteredGroups = groupsWithWorkspaceRoots.filter((group) => allowedProjectNames.has(group.projectName) || isProjectlessGroup(group))
+ return orderGroupsByWorkspaceProjectOrder(filteredGroups, rootsState, duplicateLeafNames)
+}
diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts
index 0ac873d3d..4f8862c5e 100644
--- a/src/composables/useDesktopState.ts
+++ b/src/composables/useDesktopState.ts
@@ -1,5485 +1 @@
-import { computed, ref } from 'vue'
-import {
-
- archiveThread,
- forkThread,
- getAvailableCollaborationModes,
- getAccountRateLimits,
- renameThread,
- getAvailableModelIds,
- getCurrentModelConfig,
- getPendingServerRequests,
- getSkillsList,
- getThreadDetail,
- getOlderThreadMessages,
- getBackgroundThreadListLimit,
- interruptThreadTurn,
- pickCodexRateLimitSnapshot,
- replyToServerRequest,
- revertThreadFileChanges,
- rollbackThread,
- getThreadGroupsPage,
- getThreadQueueState,
- getWorkspaceRootsState,
- setCodexSpeedMode,
- setThreadQueueState,
- setWorkspaceRootsState,
- getThreadTitleCache,
- persistThreadTitle,
- generateThreadTitle,
- resumeThread,
-
- startThread,
- subscribeCodexNotifications,
- startThreadTurn,
- type RpcNotification,
- type SkillInfo,
- type ThreadQueueState,
- type WorkspaceRootsState,
-} from '../api/codexGateway'
-import { normalizeFileChangeStatus, toUiFileChanges } from '../api/normalizers/v2'
-import type {
- CollaborationModeKind,
- CollaborationModeOption,
- CommandExecutionData,
- UiPendingRequestState,
- ReasoningEffort,
- SpeedMode,
- UiFileChange,
- UiLiveOverlay,
- UiMessage,
- UiPlanData,
- UiPlanStep,
- UiProjectGroup,
- UiRateLimitSnapshot,
- UiServerRequest,
- UiServerRequestReply,
- UiThreadTokenUsage,
- UiTokenUsageBreakdown,
- UiThread,
-} from '../types/codex'
-import { getPathParent, isProjectlessChatPath, normalizePathForUi, toProjectName } from '../pathUtils.js'
-
-function flattenThreads(groups: UiProjectGroup[]): UiThread[] {
- return groups.flatMap((group) => group.threads)
-}
-
-export function findAdjacentThreadId(threads: UiThread[], threadId: string): string {
- const targetIndex = threads.findIndex((thread) => thread.id === threadId)
- if (targetIndex < 0) return ''
- return threads[targetIndex + 1]?.id ?? threads[targetIndex - 1]?.id ?? ''
-}
-
-const READ_STATE_STORAGE_KEY = 'codex-web-local.thread-read-state.v1'
-const UNREAD_CUTOFF_STORAGE_KEY = 'codex-web-local.thread-unread-cutoff.v1'
-const THREAD_TOKEN_USAGE_STORAGE_KEY = 'codex-web-local.thread-token-usage.v1'
-const THREAD_TERMINAL_OPEN_STORAGE_KEY = 'codex-web-local.thread-terminal-open.v1'
-const SELECTED_THREAD_STORAGE_KEY = 'codex-web-local.selected-thread-id.v1'
-const SELECTED_MODEL_BY_CONTEXT_STORAGE_KEY = 'codex-web-local.selected-model-by-context.v1'
-const LEGACY_SELECTED_MODEL_STORAGE_KEY = 'codex-web-local.selected-model-id.v1'
-const PROJECT_ORDER_STORAGE_KEY = 'codex-web-local.project-order.v1'
-const PROJECT_DISPLAY_NAME_STORAGE_KEY = 'codex-web-local.project-display-name.v1'
-const COLLABORATION_MODE_STORAGE_KEY = 'codex-web-local.collaboration-mode-by-context.v1'
-const LEGACY_COLLABORATION_MODE_STORAGE_KEY = 'codex-web-local.collaboration-mode.v1'
-const NEW_THREAD_COLLABORATION_MODE_CONTEXT = '__new-thread__'
-const NEW_THREAD_PROVIDER_MODEL_CONTEXT_PREFIX = '__new-thread-provider__::'
-const EVENT_SYNC_DEBOUNCE_MS = 220
-const BACKGROUND_THREAD_PAGINATION_DELAY_MS = 10_000
-const RATE_LIMIT_REFRESH_DEBOUNCE_MS = 500
-const TURN_START_FOLLOW_UP_SYNC_DELAY_MS = 3000
-const RECENT_THREAD_MESSAGE_LOAD_REUSE_MS = 2000
-const REASONING_EFFORT_OPTIONS: ReasoningEffort[] = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
-const GLOBAL_SERVER_REQUEST_SCOPE = '__global__'
-const MODEL_FALLBACK_ID = 'gpt-5.4-mini'
-
-function loadReadStateMap(): Record {
- if (typeof window === 'undefined') return {}
-
- try {
- const raw = window.localStorage.getItem(READ_STATE_STORAGE_KEY)
- if (!raw) return {}
-
- const parsed = JSON.parse(raw) as unknown
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
- return parsed as Record
- } catch {
- return {}
- }
-}
-
-function saveReadStateMap(state: Record): void {
- if (typeof window === 'undefined') return
- window.localStorage.setItem(READ_STATE_STORAGE_KEY, JSON.stringify(state))
-}
-
-function loadUnreadCutoffIso(): string {
- if (typeof window === 'undefined') return ''
-
- const existing = window.localStorage.getItem(UNREAD_CUTOFF_STORAGE_KEY)
- if (existing) return existing
-
- const initialCutoff = new Date().toISOString()
- window.localStorage.setItem(UNREAD_CUTOFF_STORAGE_KEY, initialCutoff)
- return initialCutoff
-}
-
-function saveUnreadCutoffIso(cutoffIso: string): void {
- if (typeof window === 'undefined') return
- window.localStorage.setItem(UNREAD_CUTOFF_STORAGE_KEY, cutoffIso)
-}
-
-function isThreadUpdatedAfterCutoff(updatedAtIso: string, cutoffIso: string): boolean {
- if (!updatedAtIso || !cutoffIso) return false
- const updatedAtMs = new Date(updatedAtIso).getTime()
- const cutoffMs = new Date(cutoffIso).getTime()
- if (!Number.isFinite(updatedAtMs) || !Number.isFinite(cutoffMs)) return false
- return updatedAtMs > cutoffMs
-}
-
-export function isThreadUnreadByLastRead(
- updatedAtIso: string,
- threadReadStateIso: string | undefined,
- unreadCutoffIso: string,
-): boolean {
- const effectiveLastReadIso = threadReadStateIso ?? unreadCutoffIso
- return isThreadUpdatedAfterCutoff(updatedAtIso, effectiveLastReadIso)
-}
-
-function normalizeCollaborationMode(value: unknown): CollaborationModeKind {
- return value === 'plan' ? 'plan' : 'default'
-}
-
-function normalizeStoredModelId(value: unknown): string {
- return typeof value === 'string' ? value.trim() : ''
-}
-
-function createStringKeyedRecord(): Record {
- return Object.create(null) as Record
-}
-
-function cloneStringKeyedRecord(record: Record): Record {
- const next = createStringKeyedRecord()
- for (const [key, value] of Object.entries(record)) {
- next[key] = value
- }
- return next
-}
-
-function omitStringKeyedRecordKey(record: Record, key: string): Record {
- if (!(key in record)) return record
- const next = createStringKeyedRecord()
- for (const [entryKey, value] of Object.entries(record)) {
- if (entryKey !== key) {
- next[entryKey] = value
- }
- }
- return next
-}
-
-function pruneThreadContextStateMap(
- stateMap: Record,
- threadIds: Set,
-): Record {
- let changed = false
- const next = createStringKeyedRecord()
- for (const [contextId, value] of Object.entries(stateMap)) {
- if (
- contextId === NEW_THREAD_COLLABORATION_MODE_CONTEXT
- || contextId.startsWith(NEW_THREAD_PROVIDER_MODEL_CONTEXT_PREFIX)
- || threadIds.has(contextId)
- ) {
- next[contextId] = value
- continue
- }
- changed = true
- }
- return changed ? next : stateMap
-}
-
-function normalizeProviderContextId(providerId: string): string {
- const normalized = providerId.trim().toLowerCase()
- return normalized || 'codex'
-}
-
-function isNewThreadContextId(contextId: string): boolean {
- return contextId === NEW_THREAD_COLLABORATION_MODE_CONTEXT
-}
-
-function toProviderModelContextId(providerId: string): string {
- const normalizedProviderId = normalizeProviderContextId(providerId)
- if (!normalizedProviderId) return ''
- return `${NEW_THREAD_PROVIDER_MODEL_CONTEXT_PREFIX}${normalizedProviderId}`
-}
-
-function toThreadContextId(threadId: string): string {
- const normalizedThreadId = threadId.trim()
- return normalizedThreadId || NEW_THREAD_COLLABORATION_MODE_CONTEXT
-}
-
-function loadSelectedModelMap(): Record {
- if (typeof window === 'undefined') return createStringKeyedRecord()
-
- try {
- const raw = window.localStorage.getItem(SELECTED_MODEL_BY_CONTEXT_STORAGE_KEY)
- if (raw) {
- const parsed = JSON.parse(raw) as unknown
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return createStringKeyedRecord()
-
- const next = createStringKeyedRecord()
- for (const [contextId, value] of Object.entries(parsed as Record)) {
- if (typeof contextId !== 'string' || contextId.length === 0) continue
- const normalizedModelId = normalizeStoredModelId(value)
- if (normalizedModelId) {
- next[contextId] = normalizedModelId
- }
- }
- return next
- }
- } catch {
- // Fall back to the legacy global preference below.
- }
-
- const legacyModelId = normalizeStoredModelId(window.localStorage.getItem(LEGACY_SELECTED_MODEL_STORAGE_KEY))
- const next = createStringKeyedRecord()
- if (legacyModelId) {
- next[NEW_THREAD_COLLABORATION_MODE_CONTEXT] = legacyModelId
- }
- return next
-}
-
-function readSelectedModel(
- state: Record,
- threadId: string,
-): string {
- const contextId = toThreadContextId(threadId)
- const contextModelId = normalizeStoredModelId(state[contextId])
- if (contextModelId) return contextModelId
- return normalizeStoredModelId(state[NEW_THREAD_COLLABORATION_MODE_CONTEXT])
-}
-
-function saveSelectedModelMap(state: Record): void {
- if (typeof window === 'undefined') return
- try {
- if (Object.keys(state).length === 0) {
- window.localStorage.removeItem(SELECTED_MODEL_BY_CONTEXT_STORAGE_KEY)
- } else {
- window.localStorage.setItem(SELECTED_MODEL_BY_CONTEXT_STORAGE_KEY, JSON.stringify(state))
- }
- window.localStorage.removeItem(LEGACY_SELECTED_MODEL_STORAGE_KEY)
- } catch {
- // Keep in-memory selection working even if localStorage writes fail.
- }
-}
-
-function loadSelectedCollaborationModeMap(): Record {
- if (typeof window === 'undefined') return createStringKeyedRecord()
-
- try {
- const raw = window.localStorage.getItem(COLLABORATION_MODE_STORAGE_KEY)
- if (raw) {
- const parsed = JSON.parse(raw) as unknown
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
- return createStringKeyedRecord()
- }
-
- const next = createStringKeyedRecord()
- for (const [contextId, value] of Object.entries(parsed as Record)) {
- if (typeof contextId !== 'string' || contextId.length === 0) continue
- const normalizedMode = normalizeCollaborationMode(value)
- if (normalizedMode === 'plan') {
- next[contextId] = normalizedMode
- }
- }
- return next
- }
- } catch {
- // Fall back to the legacy global preference below.
- }
-
- return createStringKeyedRecord()
-}
-
-function readSelectedCollaborationMode(
- state: Record,
- threadId: string,
-): CollaborationModeKind {
- const contextId = toThreadContextId(threadId)
- return normalizeCollaborationMode(state[contextId])
-}
-
-function writeSelectedCollaborationModeForContext(
- state: Record,
- threadId: string,
- mode: CollaborationModeKind,
-): Record {
- const contextId = toThreadContextId(threadId)
- if (isNewThreadContextId(contextId)) {
- return omitStringKeyedRecordKey(state, contextId)
- }
- if (mode === 'plan') {
- const next = cloneStringKeyedRecord(state)
- next[contextId] = 'plan'
- return next
- }
- return omitStringKeyedRecordKey(state, contextId)
-}
-
-function saveSelectedCollaborationModeMap(state: Record): void {
- if (typeof window === 'undefined') return
- try {
- if (Object.keys(state).length === 0) {
- window.localStorage.removeItem(COLLABORATION_MODE_STORAGE_KEY)
- } else {
- window.localStorage.setItem(COLLABORATION_MODE_STORAGE_KEY, JSON.stringify(state))
- }
- window.localStorage.removeItem(LEGACY_COLLABORATION_MODE_STORAGE_KEY)
- } catch {
- // Keep in-memory mode selection working even if localStorage writes fail.
- }
-}
-
-function clamp(value: number, minValue: number, maxValue: number): number {
- return Math.min(Math.max(value, minValue), maxValue)
-}
-
-function normalizeStoredTokenCount(value: unknown): number | null {
- if (typeof value === 'number' && Number.isFinite(value)) {
- return Math.max(0, Math.trunc(value))
- }
-
- if (typeof value === 'string' && value.trim().length > 0) {
- const parsed = Number(value)
- if (Number.isFinite(parsed)) {
- return Math.max(0, Math.trunc(parsed))
- }
- }
-
- return null
-}
-
-function normalizeTokenUsageBreakdown(value: unknown): UiThreadTokenUsage['last'] | null {
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null
-
- const record = value as Record
- return {
- totalTokens: normalizeStoredTokenCount(record.totalTokens) ?? 0,
- inputTokens: normalizeStoredTokenCount(record.inputTokens) ?? 0,
- cachedInputTokens: normalizeStoredTokenCount(record.cachedInputTokens) ?? 0,
- outputTokens: normalizeStoredTokenCount(record.outputTokens) ?? 0,
- reasoningOutputTokens: normalizeStoredTokenCount(record.reasoningOutputTokens) ?? 0,
- }
-}
-
-function normalizeThreadTokenUsage(value: unknown): UiThreadTokenUsage | null {
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null
-
- const record = value as Record
- const total = normalizeTokenUsageBreakdown(record.total)
- const last = normalizeTokenUsageBreakdown(record.last)
- if (!total || !last) return null
-
- const modelContextWindow = normalizeStoredTokenCount(record.modelContextWindow)
- const currentContextTokens = last.totalTokens
- const remainingContextTokens = typeof modelContextWindow === 'number'
- ? Math.max(modelContextWindow - currentContextTokens, 0)
- : null
- const remainingContextPercent = typeof modelContextWindow === 'number' && modelContextWindow > 0
- ? clamp(Math.round((remainingContextTokens ?? 0) / modelContextWindow * 100), 0, 100)
- : null
-
- return {
- total,
- last,
- modelContextWindow,
- currentContextTokens,
- remainingContextTokens,
- remainingContextPercent,
- }
-}
-
-function loadThreadTokenUsageMap(): Record {
- if (typeof window === 'undefined') return {}
-
- try {
- const raw = window.localStorage.getItem(THREAD_TOKEN_USAGE_STORAGE_KEY)
- if (!raw) return {}
-
- const parsed = JSON.parse(raw) as unknown
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
-
- const normalizedMap: Record = {}
- for (const [threadId, usage] of Object.entries(parsed as Record)) {
- if (!threadId) continue
- const normalizedUsage = normalizeThreadTokenUsage(usage)
- if (normalizedUsage) {
- normalizedMap[threadId] = normalizedUsage
- }
- }
- return normalizedMap
- } catch {
- return {}
- }
-}
-
-function saveThreadTokenUsageMap(state: Record): void {
- if (typeof window === 'undefined') return
- window.localStorage.setItem(THREAD_TOKEN_USAGE_STORAGE_KEY, JSON.stringify(state))
-}
-
-function loadThreadTerminalOpenMap(): Record {
- if (typeof window === 'undefined') return {}
-
- try {
- const raw = window.localStorage.getItem(THREAD_TERMINAL_OPEN_STORAGE_KEY)
- if (!raw) return {}
-
- const parsed = JSON.parse(raw) as unknown
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
-
- const normalizedMap: Record = {}
- for (const [threadId, isOpen] of Object.entries(parsed as Record)) {
- if (threadId && typeof isOpen === 'boolean') {
- normalizedMap[threadId] = isOpen
- }
- }
- return normalizedMap
- } catch {
- return {}
- }
-}
-
-function saveThreadTerminalOpenMap(state: Record): void {
- if (typeof window === 'undefined') return
- window.localStorage.setItem(THREAD_TERMINAL_OPEN_STORAGE_KEY, JSON.stringify(state))
-}
-
-function loadSelectedThreadId(): string {
- if (typeof window === 'undefined') return ''
- const raw = window.localStorage.getItem(SELECTED_THREAD_STORAGE_KEY)
- return raw ?? ''
-}
-
-function saveSelectedThreadId(threadId: string): void {
- if (typeof window === 'undefined') return
- if (!threadId) {
- window.localStorage.removeItem(SELECTED_THREAD_STORAGE_KEY)
- return
- }
- window.localStorage.setItem(SELECTED_THREAD_STORAGE_KEY, threadId)
-}
-
-function loadProjectOrder(): string[] {
- if (typeof window === 'undefined') return []
-
- try {
- const raw = window.localStorage.getItem(PROJECT_ORDER_STORAGE_KEY)
- if (!raw) return []
-
- const parsed = JSON.parse(raw) as unknown
- if (!Array.isArray(parsed)) return []
- const order: string[] = []
- for (const item of parsed) {
- if (typeof item !== 'string' || item.length === 0) continue
- const normalizedItem = toProjectName(item)
- if (normalizedItem.length > 0 && !order.includes(normalizedItem)) {
- order.push(normalizedItem)
- }
- }
- return order
- } catch {
- return []
- }
-}
-
-function saveProjectOrder(order: string[]): void {
- if (typeof window === 'undefined') return
- window.localStorage.setItem(PROJECT_ORDER_STORAGE_KEY, JSON.stringify(order))
-}
-
-function loadProjectDisplayNames(): Record {
- if (typeof window === 'undefined') return {}
-
- try {
- const raw = window.localStorage.getItem(PROJECT_DISPLAY_NAME_STORAGE_KEY)
- if (!raw) return {}
-
- const parsed = JSON.parse(raw) as unknown
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
-
- const displayNames: Record = {}
- for (const [projectName, displayName] of Object.entries(parsed as Record)) {
- const normalizedProjectName = typeof projectName === 'string' ? toProjectName(projectName) : ''
- if (normalizedProjectName.length > 0 && typeof displayName === 'string') {
- displayNames[normalizedProjectName] = displayName
- }
- }
- return displayNames
- } catch {
- return {}
- }
-}
-
-function saveProjectDisplayNames(displayNames: Record): void {
- if (typeof window === 'undefined') return
- window.localStorage.setItem(PROJECT_DISPLAY_NAME_STORAGE_KEY, JSON.stringify(displayNames))
-}
-
-function mergeProjectOrder(previousOrder: string[], incomingGroups: UiProjectGroup[]): string[] {
- const nextOrder: string[] = []
-
- for (const projectName of previousOrder) {
- if (!nextOrder.includes(projectName)) {
- nextOrder.push(projectName)
- }
- }
-
- for (const group of incomingGroups) {
- if (!nextOrder.includes(group.projectName)) {
- nextOrder.push(group.projectName)
- }
- }
-
- return areStringArraysEqual(previousOrder, nextOrder) ? previousOrder : nextOrder
-}
-
-function orderGroupsByProjectOrder(incoming: UiProjectGroup[], projectOrder: string[]): UiProjectGroup[] {
- const incomingByName = new Map(incoming.map((group) => [group.projectName, group]))
- const ordered: UiProjectGroup[] = projectOrder
- .map((projectName) => incomingByName.get(projectName) ?? null)
- .filter((group): group is UiProjectGroup => group !== null)
-
- for (const group of incoming) {
- if (!projectOrder.includes(group.projectName)) {
- ordered.push(group)
- }
- }
-
- return ordered
-}
-
-function areStringArraysEqual(first?: string[], second?: string[]): boolean {
- const left = Array.isArray(first) ? first : []
- const right = Array.isArray(second) ? second : []
- if (left.length !== right.length) return false
- for (let index = 0; index < left.length; index += 1) {
- if (left[index] !== right[index]) return false
- }
- return true
-}
-
-function reorderStringArray(items: string[], fromIndex: number, toIndex: number): string[] {
- if (fromIndex < 0 || fromIndex >= items.length || toIndex < 0 || toIndex >= items.length) {
- return items
- }
-
- if (fromIndex === toIndex) {
- return items
- }
-
- const next = [...items]
- const [moved] = next.splice(fromIndex, 1)
- next.splice(toIndex, 0, moved)
- return next
-}
-
-function areCommandExecutionsEqual(first?: CommandExecutionData, second?: CommandExecutionData): boolean {
- if (!first && !second) return true
- if (!first || !second) return false
- return first.status === second.status && first.aggregatedOutput === second.aggregatedOutput && first.exitCode === second.exitCode
-}
-
-function arePlanStepsEqual(first: UiPlanStep[] = [], second: UiPlanStep[] = []): boolean {
- if (first.length !== second.length) return false
- for (let index = 0; index < first.length; index += 1) {
- if (first[index]?.step !== second[index]?.step || first[index]?.status !== second[index]?.status) {
- return false
- }
- }
- return true
-}
-
-function arePlanDataEqual(first?: UiPlanData, second?: UiPlanData): boolean {
- if (!first && !second) return true
- if (!first || !second) return false
- return (
- first.explanation === second.explanation &&
- first.isStreaming === second.isStreaming &&
- arePlanStepsEqual(first.steps, second.steps)
- )
-}
-
-function isUnsupportedChatGptModelError(error: unknown): boolean {
- if (!(error instanceof Error)) return false
- const message = error.message.toLowerCase()
- return (
- message.includes('not supported when using codex with a chatgpt account') ||
- message.includes('model is not supported') ||
- message.includes('requires a newer version of codex')
- )
-}
-
-function areMessageFieldsEqual(first: UiMessage, second: UiMessage): boolean {
- return (
- first.id === second.id &&
- first.role === second.role &&
- first.text === second.text &&
- areStringArraysEqual(first.images, second.images) &&
- areUiFileChangesEqual(first.fileChanges, second.fileChanges) &&
- first.fileChangeStatus === second.fileChangeStatus &&
- first.messageType === second.messageType &&
- first.rawPayload === second.rawPayload &&
- first.isUnhandled === second.isUnhandled &&
- areCommandExecutionsEqual(first.commandExecution, second.commandExecution) &&
- arePlanDataEqual(first.plan, second.plan) &&
- first.turnId === second.turnId &&
- first.turnIndex === second.turnIndex &&
- first.isAutomationRun === second.isAutomationRun &&
- first.automationDisplayName === second.automationDisplayName
- )
-}
-
-function areMessageArraysEqual(first: UiMessage[], second: UiMessage[]): boolean {
- if (first.length !== second.length) return false
- for (let index = 0; index < first.length; index += 1) {
- if (first[index] !== second[index]) return false
- }
- return true
-}
-
-function mergeMessages(
- previous: UiMessage[],
- incoming: UiMessage[],
- options: { preserveMissing?: boolean } = {},
-): UiMessage[] {
- const previousById = new Map(previous.map((message) => [message.id, message]))
- const incomingById = new Map(incoming.map((message) => [message.id, message]))
-
- const mergedIncoming = incoming.map((incomingMessage) => {
- const previousMessage = previousById.get(incomingMessage.id)
- if (previousMessage && areMessageFieldsEqual(previousMessage, incomingMessage)) {
- return previousMessage
- }
- return incomingMessage
- })
-
- if (options.preserveMissing !== true) {
- return areMessageArraysEqual(previous, mergedIncoming) ? previous : mergedIncoming
- }
-
- const mergedFromPrevious = previous.map((previousMessage) => {
- const nextMessage = incomingById.get(previousMessage.id)
- if (!nextMessage) {
- return previousMessage
- }
- if (areMessageFieldsEqual(previousMessage, nextMessage)) {
- return previousMessage
- }
- return nextMessage
- })
-
- const previousIdSet = new Set(previous.map((message) => message.id))
- const appended = mergedIncoming.filter((message) => !previousIdSet.has(message.id))
- const merged = [...mergedFromPrevious, ...appended]
-
- return areMessageArraysEqual(previous, merged) ? previous : merged
-}
-
-function areUiFileChangesEqual(first?: UiFileChange[], second?: UiFileChange[]): boolean {
- if (!first && !second) return true
- if (!first || !second) return false
- if (first.length !== second.length) return false
- for (let index = 0; index < first.length; index += 1) {
- const firstChange = first[index]
- const secondChange = second[index]
- if (
- firstChange.path !== secondChange.path ||
- firstChange.operation !== secondChange.operation ||
- firstChange.movedToPath !== secondChange.movedToPath ||
- firstChange.diff !== secondChange.diff ||
- firstChange.addedLineCount !== secondChange.addedLineCount ||
- firstChange.removedLineCount !== secondChange.removedLineCount
- ) {
- return false
- }
- }
- return true
-}
-
-function normalizeMessageText(value: string): string {
- return value.replace(/\s+/gu, ' ').trim()
-}
-
-function removeRedundantLiveAgentMessages(previous: UiMessage[], incoming: UiMessage[]): UiMessage[] {
- const incomingMessageIds = new Set(incoming.map((message) => message.id))
- const incomingAssistantTexts = new Set(
- incoming
- .filter((message) => message.role === 'assistant')
- .map((message) => normalizeMessageText(message.text))
- .filter((text) => text.length > 0),
- )
-
- if (incomingAssistantTexts.size === 0) {
- return previous
- }
-
- const next = previous.filter((message) => {
- if (message.messageType !== 'agentMessage.live') return true
- if (incomingMessageIds.has(message.id)) return false
- const normalized = normalizeMessageText(message.text)
- if (normalized.length === 0) return false
- return !incomingAssistantTexts.has(normalized)
- })
-
- return next.length === previous.length ? previous : next
-}
-
-function removePersistedLiveMessages(previous: UiMessage[], incoming: UiMessage[]): UiMessage[] {
- const incomingIds = new Set(incoming.map((message) => message.id))
- const next = previous.filter((message) => !incomingIds.has(message.id))
- return next.length === previous.length ? previous : next
-}
-
-function upsertMessage(previous: UiMessage[], nextMessage: UiMessage): UiMessage[] {
- const existingIndex = previous.findIndex((message) => message.id === nextMessage.id)
- if (existingIndex < 0) {
- return [...previous, nextMessage]
- }
-
- const existing = previous[existingIndex]
- if (areMessageFieldsEqual(existing, nextMessage)) {
- return previous
- }
-
- const next = [...previous]
- next.splice(existingIndex, 1, nextMessage)
- return next
-}
-
-type TurnSummaryState = {
- turnId: string
- durationMs: number
-}
-
-type TurnActivityState = {
- label: string
- details: string[]
-}
-
-type TurnErrorState = {
- message: string
- transient: boolean
-}
-
-type TurnStartedInfo = {
- threadId: string
- turnId: string
- startedAtMs: number
-}
-
-type TurnCompletedInfo = {
- threadId: string
- turnId: string
- completedAtMs: number
- startedAtMs?: number
-}
-
-const WORKED_MESSAGE_TYPE = 'worked'
-
-function parseIsoTimestamp(value: string): number | null {
- if (!value) return null
- const ms = new Date(value).getTime()
- return Number.isNaN(ms) ? null : ms
-}
-
-function formatTurnDuration(durationMs: number): string {
- if (!Number.isFinite(durationMs) || durationMs <= 0) {
- return '<1s'
- }
-
- const totalSeconds = Math.max(1, Math.round(durationMs / 1000))
- const hours = Math.floor(totalSeconds / 3600)
- const minutes = Math.floor((totalSeconds % 3600) / 60)
- const seconds = totalSeconds % 60
- const parts: string[] = []
-
- if (hours > 0) {
- parts.push(`${hours}h`)
- }
-
- if (minutes > 0 || hours > 0) {
- parts.push(`${minutes}m`)
- }
-
- const displaySeconds = seconds > 0 || parts.length === 0 ? seconds : 0
- parts.push(`${displaySeconds}s`)
- return parts.join(' ')
-}
-
-function areTurnSummariesEqual(first?: TurnSummaryState, second?: TurnSummaryState): boolean {
- if (!first && !second) return true
- if (!first || !second) return false
- return first.turnId === second.turnId && first.durationMs === second.durationMs
-}
-
-function areTurnActivitiesEqual(first?: TurnActivityState, second?: TurnActivityState): boolean {
- if (!first && !second) return true
- if (!first || !second) return false
- if (first.label !== second.label) return false
- if (first.details.length !== second.details.length) return false
- for (let index = 0; index < first.details.length; index += 1) {
- if (first.details[index] !== second.details[index]) return false
- }
- return true
-}
-
-function buildTurnSummaryMessage(summary: TurnSummaryState): UiMessage {
- return {
- id: `turn-summary:${summary.turnId}`,
- role: 'system',
- text: `Worked for ${formatTurnDuration(summary.durationMs)}`,
- messageType: WORKED_MESSAGE_TYPE,
- turnId: summary.turnId,
- }
-}
-
-function findLastAssistantMessageIndex(messages: UiMessage[]): number {
- for (let index = messages.length - 1; index >= 0; index -= 1) {
- if (messages[index].role === 'assistant') {
- return index
- }
- }
- return -1
-}
-
-function insertTurnSummaryMessage(messages: UiMessage[], summary: TurnSummaryState): UiMessage[] {
- const summaryMessage = buildTurnSummaryMessage(summary)
- const sanitizedMessages = messages.filter((message) => message.messageType !== WORKED_MESSAGE_TYPE)
- const insertIndex = findLastAssistantMessageIndex(sanitizedMessages)
- if (insertIndex < 0) {
- return [...sanitizedMessages, summaryMessage]
- }
- const next = [...sanitizedMessages]
- next.splice(insertIndex, 0, summaryMessage)
- return next
-}
-
-function omitKey(record: Record, key: string): Record {
- if (!(key in record)) return record
- const next = { ...record }
- delete next[key]
- return next
-}
-
-function omitKeys(record: Record, keys: Set): Record {
- if (keys.size === 0) return record
- let changed = false
- const next: Record = {}
- for (const [key, value] of Object.entries(record)) {
- if (keys.has(key)) {
- changed = true
- continue
- }
- next[key] = value
- }
- return changed ? next : record
-}
-
-function areThreadFieldsEqual(first: UiThread, second: UiThread): boolean {
- return (
- first.id === second.id &&
- first.title === second.title &&
- first.projectName === second.projectName &&
- first.cwd === second.cwd &&
- first.createdAtIso === second.createdAtIso &&
- first.updatedAtIso === second.updatedAtIso &&
- first.preview === second.preview &&
- first.unread === second.unread &&
- first.inProgress === second.inProgress &&
- first.pendingRequestState === second.pendingRequestState
- )
-}
-
-function areThreadArraysEqual(first: UiThread[], second: UiThread[]): boolean {
- if (first.length !== second.length) return false
- for (let index = 0; index < first.length; index += 1) {
- if (first[index] !== second[index]) return false
- }
- return true
-}
-
-function areGroupArraysEqual(first: UiProjectGroup[], second: UiProjectGroup[]): boolean {
- if (first.length !== second.length) return false
- for (let index = 0; index < first.length; index += 1) {
- if (first[index] !== second[index]) return false
- }
- return true
-}
-
-function pruneThreadStateMap(stateMap: Record, threadIds: Set): Record {
- const nextEntries = Object.entries(stateMap).filter(([threadId]) => threadIds.has(threadId))
- if (nextEntries.length === Object.keys(stateMap).length) {
- return stateMap
- }
- return Object.fromEntries(nextEntries) as Record
-}
-
-export function removeThreadFromGroups(groups: UiProjectGroup[], threadId: string): UiProjectGroup[] {
- const normalizedThreadId = threadId.trim()
- if (!normalizedThreadId) return groups
-
- let changed = false
- const nextGroups: UiProjectGroup[] = []
-
- for (const group of groups) {
- const nextThreads = group.threads.filter((thread) => thread.id !== normalizedThreadId)
- const removedFromGroup = nextThreads.length !== group.threads.length
- if (removedFromGroup) {
- changed = true
- }
- if (nextThreads.length > 0) {
- nextGroups.push(removedFromGroup ? { ...group, threads: nextThreads } : group)
- } else if (group.threads.length === 0) {
- nextGroups.push(group)
- }
- }
-
- return changed ? nextGroups : groups
-}
-
-function mergeThreadGroups(
- previous: UiProjectGroup[],
- incoming: UiProjectGroup[],
-): UiProjectGroup[] {
- const previousGroupsByName = new Map(previous.map((group) => [group.projectName, group]))
- const mergedGroups: UiProjectGroup[] = incoming.map((incomingGroup) => {
- const previousGroup = previousGroupsByName.get(incomingGroup.projectName)
- const previousThreadsById = new Map(previousGroup?.threads.map((thread) => [thread.id, thread]) ?? [])
-
- const mergedThreads = incomingGroup.threads.map((incomingThread) => {
- const previousThread = previousThreadsById.get(incomingThread.id)
- if (previousThread && areThreadFieldsEqual(previousThread, incomingThread)) {
- return previousThread
- }
- return incomingThread
- })
-
- if (
- previousGroup &&
- previousGroup.projectName === incomingGroup.projectName &&
- areThreadArraysEqual(previousGroup.threads, mergedThreads)
- ) {
- return previousGroup
- }
-
- return {
- projectName: incomingGroup.projectName,
- threads: mergedThreads,
- }
- })
-
- return areGroupArraysEqual(previous, mergedGroups) ? previous : mergedGroups
-}
-
-function mergeIncomingWithLocalInProgressThreads(
- previous: UiProjectGroup[],
- incoming: UiProjectGroup[],
- inProgressById: Record,
-): UiProjectGroup[] {
- const incomingThreadIds = new Set(flattenThreads(incoming).map((thread) => thread.id))
- const localInProgressThreads = flattenThreads(previous).filter(
- (thread) => inProgressById[thread.id] === true && !incomingThreadIds.has(thread.id),
- )
-
- if (localInProgressThreads.length === 0) {
- return incoming
- }
-
- const incomingByProjectName = new Map(incoming.map((group) => [group.projectName, group]))
- const merged: UiProjectGroup[] = incoming.map((group) => ({
- projectName: group.projectName,
- threads: [...group.threads],
- }))
-
- for (const thread of localInProgressThreads) {
- const existingGroup = incomingByProjectName.get(thread.projectName)
- if (existingGroup) {
- const mergedGroupIndex = merged.findIndex((group) => group.projectName === thread.projectName)
- if (mergedGroupIndex >= 0) {
- merged[mergedGroupIndex] = {
- projectName: merged[mergedGroupIndex].projectName,
- threads: [thread, ...merged[mergedGroupIndex].threads],
- }
- }
- continue
- }
-
- merged.push({
- projectName: thread.projectName,
- threads: [thread],
- })
- }
-
- return merged
-}
-
-function toProjectNameFromWorkspaceRoot(value: string): string {
- return toProjectName(value)
-}
-
-function getRemoteProjectHostLabel(hostId: string): string {
- const normalized = hostId.trim()
- if (!normalized) return ''
- const separatorIndex = normalized.lastIndexOf(':')
- return separatorIndex >= 0 ? normalized.slice(separatorIndex + 1) : normalized
-}
-
-function getRemoteProjectDisplayName(remoteProject: NonNullable[number]): string {
- const label = remoteProject.label || toProjectName(remoteProject.remotePath) || remoteProject.id
- const hostLabel = getRemoteProjectHostLabel(remoteProject.hostId)
- return hostLabel ? `${label} ${hostLabel}` : label
-}
-
-function getRemoteProjectById(rootsState: WorkspaceRootsState | null): Map[number]> {
- const remoteProjects = rootsState?.remoteProjects ?? []
- return new Map(remoteProjects.map((project) => [project.id, project]))
-}
-
-function getWorkspaceProjectOrderPaths(rootsState: WorkspaceRootsState | null): string[] {
- if (!rootsState) return []
- const savedRoots = new Set(rootsState.order)
- const remoteProjectIds = new Set((rootsState.remoteProjects ?? []).map((project) => project.id))
- const orderedRoots = rootsState.projectOrder.filter((item) => savedRoots.has(item) || remoteProjectIds.has(item))
- for (const rootPath of rootsState.order) {
- if (!orderedRoots.includes(rootPath)) orderedRoots.push(rootPath)
- }
- for (const remoteProjectId of remoteProjectIds) {
- if (!orderedRoots.includes(remoteProjectId)) orderedRoots.push(remoteProjectId)
- }
- return orderedRoots
-}
-
-function getWorkspaceProjectOrderNames(
- rootsState: WorkspaceRootsState | null,
- duplicateLeafNames: Set,
-): string[] {
- const remoteProjectsById = getRemoteProjectById(rootsState)
- return getWorkspaceProjectOrderPaths(rootsState).map((rootPath) => {
- if (remoteProjectsById.has(rootPath)) return rootPath
- const normalizedRootPath = normalizePathForUi(rootPath).trim()
- const leafName = toProjectNameFromWorkspaceRoot(normalizedRootPath)
- return duplicateLeafNames.has(leafName) ? normalizedRootPath : leafName
- })
-}
-
-function matchesWorkspaceRootProject(rootPath: string, projectName: string): boolean {
- const normalizedRootPath = normalizePathForUi(rootPath).trim()
- return normalizedRootPath === projectName || toProjectNameFromWorkspaceRoot(rootPath) === projectName
-}
-
-export function collectWorkspaceRootPathsForProjectRemoval(
- rootsState: WorkspaceRootsState,
- projectName: string,
-): Set {
- const removedRootPaths = new Set()
- for (const rootPath of rootsState.order) {
- if (matchesWorkspaceRootProject(rootPath, projectName)) {
- removedRootPaths.add(rootPath)
- }
- }
- for (const rootPath of rootsState.active) {
- if (matchesWorkspaceRootProject(rootPath, projectName)) {
- removedRootPaths.add(rootPath)
- }
- }
- for (const rootPath of Object.keys(rootsState.labels)) {
- if (matchesWorkspaceRootProject(rootPath, projectName)) {
- removedRootPaths.add(rootPath)
- }
- }
- return removedRootPaths
-}
-
-export function buildWorkspaceRootsProjectOrderState(
- rootsState: WorkspaceRootsState,
- orderedProjectNames: string[],
- groups: UiProjectGroup[],
-): Pick {
- const remoteProjectIds = new Set((rootsState.remoteProjects ?? []).map((project) => project.id))
- const rootByProjectName = new Map()
- for (const rootPath of rootsState.order) {
- const projectName = toProjectNameFromWorkspaceRoot(rootPath)
- if (!rootByProjectName.has(projectName)) {
- rootByProjectName.set(projectName, rootPath)
- }
- }
- for (const group of groups) {
- const cwd = group.threads[0]?.cwd?.trim() ?? ''
- if (!cwd) continue
- rootByProjectName.set(group.projectName, cwd)
- }
-
- const nextProjectOrder: string[] = []
- const pushProjectOrderItem = (item: string): void => {
- if (item && !nextProjectOrder.includes(item)) {
- nextProjectOrder.push(item)
- }
- }
-
- for (const projectName of orderedProjectNames) {
- if (remoteProjectIds.has(projectName)) {
- pushProjectOrderItem(projectName)
- continue
- }
- const rootPath = rootByProjectName.get(projectName)
- if (rootPath) {
- pushProjectOrderItem(rootPath)
- }
- }
- for (const item of getWorkspaceProjectOrderPaths(rootsState)) {
- pushProjectOrderItem(item)
- }
-
- const nextOrder = nextProjectOrder.filter((item) => rootsState.order.includes(item))
- for (const rootPath of rootsState.order) {
- if (!nextOrder.includes(rootPath)) {
- nextOrder.push(rootPath)
- }
- }
-
- const nextActive = rootsState.active.filter((rootPath) => nextOrder.includes(rootPath))
- if (nextActive.length === 0 && nextOrder.length > 0) {
- nextActive.push(nextOrder[0])
- }
-
- return {
- order: nextOrder,
- active: nextActive,
- projectOrder: nextProjectOrder,
- }
-}
-
-function orderGroupsByWorkspaceProjectOrder(
- groups: UiProjectGroup[],
- rootsState: WorkspaceRootsState | null,
- duplicateLeafNames: Set,
-): UiProjectGroup[] {
- const order = getWorkspaceProjectOrderNames(rootsState, duplicateLeafNames)
- if (order.length === 0) return groups
- const orderIndexByName = new Map(order.map((name, index) => [name, index]))
- return [...groups].sort((first, second) => {
- if (isProjectlessGroup(first) || isProjectlessGroup(second)) return 0
- const firstIndex = orderIndexByName.get(first.projectName) ?? Number.POSITIVE_INFINITY
- const secondIndex = orderIndexByName.get(second.projectName) ?? Number.POSITIVE_INFINITY
- if (firstIndex === secondIndex) return 0
- return firstIndex - secondIndex
- })
-}
-
-function collectDuplicateProjectLeafNames(groups: UiProjectGroup[], rootsState: WorkspaceRootsState | null): Set {
- const rootByLeafName = new Map>()
- const canonicalWorkspaceRootCountsByLeafName = new Map()
- const addPath = (value: string): void => {
- const normalizedPath = normalizePathForUi(value).trim()
- if (!normalizedPath) return
- const leafName = toProjectName(normalizedPath)
- const existing = rootByLeafName.get(leafName) ?? new Set()
- existing.add(normalizedPath)
- rootByLeafName.set(leafName, existing)
- }
-
- for (const rootPath of rootsState?.order ?? []) {
- const normalizedRootPath = normalizePathForUi(rootPath).trim()
- if (!normalizedRootPath) continue
- const leafName = toProjectName(normalizedRootPath)
- if (!isManagedCodexWorktreePath(normalizedRootPath)) {
- canonicalWorkspaceRootCountsByLeafName.set(leafName, (canonicalWorkspaceRootCountsByLeafName.get(leafName) ?? 0) + 1)
- }
- addPath(rootPath)
- }
- for (const group of groups) {
- for (const thread of group.threads) {
- const normalizedCwd = normalizePathForUi(thread.cwd).trim()
- const leafName = toProjectName(normalizedCwd)
- const isRegisteredRoot = rootsState?.order.some((rootPath) => normalizePathForUi(rootPath).trim() === normalizedCwd) === true
- if (isManagedCodexWorktreePath(normalizedCwd) && !isRegisteredRoot && canonicalWorkspaceRootCountsByLeafName.get(leafName) === 1) continue
- addPath(thread.cwd)
- }
- }
-
- const duplicateLeafNames = new Set()
- for (const [leafName, paths] of rootByLeafName.entries()) {
- if (paths.size > 1) duplicateLeafNames.add(leafName)
- }
- return duplicateLeafNames
-}
-
-function isManagedCodexWorktreePath(value: string): boolean {
- return value.includes('/.codex/worktrees/')
-}
-
-function disambiguateProjectGroupsByCwd(
- groups: UiProjectGroup[],
- rootsState: WorkspaceRootsState | null,
-): UiProjectGroup[] {
- const duplicateLeafNames = collectDuplicateProjectLeafNames(groups, rootsState)
- if (duplicateLeafNames.size === 0) return groups
-
- const uniqueCanonicalWorkspaceRootLeafNames = new Set()
- const duplicateCanonicalWorkspaceRootLeafNames = new Set()
- const canonicalWorkspaceRootByLeafName = new Map()
- const registeredWorkspaceRoots = new Set()
- for (const rootPath of rootsState?.order ?? []) {
- const normalizedRootPath = normalizePathForUi(rootPath).trim()
- if (!normalizedRootPath) continue
- registeredWorkspaceRoots.add(normalizedRootPath)
- if (isManagedCodexWorktreePath(normalizedRootPath)) continue
- const leafName = toProjectName(normalizedRootPath)
- if (uniqueCanonicalWorkspaceRootLeafNames.has(leafName)) {
- uniqueCanonicalWorkspaceRootLeafNames.delete(leafName)
- duplicateCanonicalWorkspaceRootLeafNames.add(leafName)
- canonicalWorkspaceRootByLeafName.delete(leafName)
- } else if (!duplicateCanonicalWorkspaceRootLeafNames.has(leafName)) {
- uniqueCanonicalWorkspaceRootLeafNames.add(leafName)
- canonicalWorkspaceRootByLeafName.set(leafName, normalizedRootPath)
- }
- }
-
- const disambiguatedGroups: UiProjectGroup[] = []
- const groupsByProjectName = new Map()
- for (const group of groups) {
- for (const thread of group.threads) {
- const normalizedCwd = normalizePathForUi(thread.cwd).trim()
- const leafName = toProjectName(normalizedCwd)
- const isRegisteredRoot = registeredWorkspaceRoots.has(normalizedCwd)
- const isCanonicalWorktreeThread = isManagedCodexWorktreePath(normalizedCwd)
- && !isRegisteredRoot
- && uniqueCanonicalWorkspaceRootLeafNames.has(leafName)
- let projectName = group.projectName
- if (isCanonicalWorktreeThread && duplicateLeafNames.has(leafName)) {
- projectName = canonicalWorkspaceRootByLeafName.get(leafName) ?? group.projectName
- } else if (normalizedCwd && duplicateLeafNames.has(leafName)) {
- projectName = normalizedCwd
- }
- const nextThread = thread.projectName === projectName ? thread : { ...thread, projectName }
- const existingGroup = groupsByProjectName.get(projectName)
- if (existingGroup) {
- existingGroup.threads.push(nextThread)
- } else {
- const nextGroup = { projectName, threads: [nextThread] }
- groupsByProjectName.set(projectName, nextGroup)
- disambiguatedGroups.push(nextGroup)
- }
- }
- }
-
- return disambiguatedGroups
-}
-
-function addWorkspaceRootPlaceholderGroups(
- groups: UiProjectGroup[],
- rootsState: WorkspaceRootsState | null,
- duplicateLeafNames: Set,
-): UiProjectGroup[] {
- if (!rootsState || (rootsState.order.length === 0 && (rootsState.remoteProjects ?? []).length === 0)) return groups
- const existingProjectNames = new Set(groups.map((group) => group.projectName))
- const nextGroups = [...groups]
- const remoteProjectsById = getRemoteProjectById(rootsState)
-
- for (const rootPath of getWorkspaceProjectOrderPaths(rootsState)) {
- if (remoteProjectsById.has(rootPath)) {
- if (existingProjectNames.has(rootPath)) continue
- nextGroups.push({ projectName: rootPath, threads: [] })
- existingProjectNames.add(rootPath)
- continue
- }
- const normalizedRootPath = normalizePathForUi(rootPath).trim()
- if (!normalizedRootPath) continue
- const leafName = toProjectNameFromWorkspaceRoot(normalizedRootPath)
- const projectName = duplicateLeafNames.has(leafName) ? normalizedRootPath : leafName
- if (existingProjectNames.has(projectName)) continue
- nextGroups.push({ projectName, threads: [] })
- existingProjectNames.add(projectName)
- }
-
- return nextGroups
-}
-
-function toOptimisticThreadTitle(message: string): string {
- const firstLine = message
- .split('\n')
- .map((line) => line.trim())
- .find((line) => line.length > 0)
-
- if (!firstLine) return 'Untitled thread'
- return firstLine.slice(0, 80)
-}
-
-function toForkedThreadTitle(title: string): string {
- const normalizedTitle = title.trim() || 'Untitled thread'
- return /^fork:\s+/iu.test(normalizedTitle) ? normalizedTitle : `Fork: ${normalizedTitle}`
-}
-
-function isProjectlessGroup(group: UiProjectGroup): boolean {
- return group.threads.some((thread) => thread.cwd.trim().length === 0 || isProjectlessChatPath(thread.cwd))
-}
-
-export function filterGroupsByWorkspaceRoots(
- groups: UiProjectGroup[],
- rootsState: WorkspaceRootsState | null,
-): UiProjectGroup[] {
- const duplicateLeafNames = collectDuplicateProjectLeafNames(groups, rootsState)
- const disambiguatedGroups = disambiguateProjectGroupsByCwd(groups, rootsState)
- const groupsWithWorkspaceRoots = addWorkspaceRootPlaceholderGroups(disambiguatedGroups, rootsState, duplicateLeafNames)
- if (!rootsState || (rootsState.order.length === 0 && (rootsState.remoteProjects ?? []).length === 0)) return groupsWithWorkspaceRoots
- const allowedProjectNames = new Set()
- for (const projectName of getWorkspaceProjectOrderNames(rootsState, duplicateLeafNames)) {
- allowedProjectNames.add(projectName)
- }
- const filteredGroups = groupsWithWorkspaceRoots.filter((group) => allowedProjectNames.has(group.projectName) || isProjectlessGroup(group))
- return orderGroupsByWorkspaceProjectOrder(filteredGroups, rootsState, duplicateLeafNames)
-}
-
-export function useDesktopState() {
- const projectGroups = ref([])
- const sourceGroups = ref([])
- const selectedThreadId = ref(loadSelectedThreadId())
- const persistedMessagesByThreadId = ref>({})
- const livePlanMessagesByThreadId = ref>({})
- const liveAgentMessagesByThreadId = ref>({})
- const liveReasoningTextByThreadId = ref>({})
- const liveCommandsByThreadId = ref>({})
- const liveFileChangeMessagesByThreadId = ref>({})
- const inProgressById = ref>({})
- type FileAttachment = { label: string; path: string; fsPath: string }
- type QueuedMessage = {
- id: string
- text: string
- imageUrls: string[]
- skills: Array<{ name: string; path: string }>
- fileAttachments: FileAttachment[]
- collaborationMode: CollaborationModeKind
- }
- type PendingTurnRequest = {
- text: string
- imageUrls: string[]
- skills: Array<{ name: string; path: string }>
- fileAttachments: FileAttachment[]
- effort: ReasoningEffort | ''
- collaborationMode: CollaborationModeKind
- fallbackRetried: boolean
- }
- const queuedMessagesByThreadId = ref>({})
- const queueProcessingByThreadId = ref>({})
- let hasLoadedPersistedQueueState = false
- const eventUnreadByThreadId = ref>({})
- const availableModelIds = ref([])
- const availableCollaborationModes = ref([
- { value: 'default', label: 'Default' },
- { value: 'plan', label: 'Plan' },
- ])
- const selectedCollaborationModeByContext = ref>(
- loadSelectedCollaborationModeMap(),
- )
- const selectedModelIdByContext = ref>(loadSelectedModelMap())
- const selectedCollaborationMode = ref(
- readSelectedCollaborationMode(selectedCollaborationModeByContext.value, selectedThreadId.value),
- )
- const selectedModelId = ref(readSelectedModel(selectedModelIdByContext.value, selectedThreadId.value))
- const selectedReasoningEffort = ref('medium')
- const selectedSpeedMode = ref('standard')
- const activeProviderId = ref('')
- const readStateByThreadId = ref>(loadReadStateMap())
- const unreadCutoffIso = ref(loadUnreadCutoffIso())
- const projectOrder = ref(loadProjectOrder())
- const projectDisplayNameById = ref>(loadProjectDisplayNames())
- const loadedVersionByThreadId = ref>({})
- const loadedMessagesByThreadId = ref>({})
- const hasMoreOlderMessagesByThreadId = ref>({})
- const loadingOlderMessagesByThreadId = ref>({})
- const resumedThreadById = ref>({})
- const turnIndexByTurnIdByThreadId = ref>>({})
- const turnSummaryByThreadId = ref>({})
- const turnActivityByThreadId = ref>({})
- const turnErrorByThreadId = ref>({})
- const activeTurnIdByThreadId = ref>({})
- const interruptBlockedUntilPersistedByThreadId = ref>({})
- const threadListedByServerById = ref>({})
- const persistedUserMessageByThreadId = ref>({})
- const pendingServerRequestsByThreadId = ref>({})
- const pendingTurnRequestByThreadId = ref>({})
- const codexRateLimit = ref(null)
- const threadTokenUsageByThreadId = ref>(loadThreadTokenUsageMap())
- const terminalOpenByThreadId = ref>(loadThreadTerminalOpenMap())
-
- const threadTitleById = ref>({})
-
- const installedSkills = ref