Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,97 @@ func (r *Recorder) RecordCompact(summary string, compactedN int) {
_ = r.writeEntry(Entry{Type: EntryCompact, Summary: summary, CompactedN: compactedN})
}

// TruncateAtUserMessage rewrites the session file keeping only entries that
// appear before the (beforeCount)th user message (0-indexed).
// If beforeCount == 0, the file is truncated to the session_start header only.
// The recorder is reset to append mode on the (now shorter) file.
// This preserves the session UUID and index entry — no new session is created.
func (r *Recorder) TruncateAtUserMessage(beforeCount int) error {
r.mu.Lock()
defer r.mu.Unlock()

if r.agentID != "" {
return fmt.Errorf("TruncateAtUserMessage not supported for teammate recorders")
}

// Close current file handle before rewriting.
if r.file != nil {
_ = r.file.Close()
r.file = nil
}

dir, err := config.SessionsDir()
if err != nil {
return err
}
filePath := filepath.Join(dir, r.uuid+".json")

// Load existing entries.
data, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
// Nothing to truncate.
return nil
}
return fmt.Errorf("read session file: %w", err)
}

// Collect entries to keep: session_start always, then everything before
// the beforeCount-th user entry. When beforeCount == 0 we keep nothing
// except the session_start header.
var keep []string
userCount := 0
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var e Entry
if err := json.Unmarshal([]byte(line), &e); err != nil {
continue
}
// Always keep the session_start header.
if e.Type == EntrySessionStart {
keep = append(keep, line)
continue
}
// Stop as soon as we reach the Nth user message.
if e.Type == EntryUser {
if userCount >= beforeCount {
break
}
userCount++
}
// For beforeCount == 0 we must not keep any non-session_start entry.
if beforeCount == 0 {
break
}
keep = append(keep, line)
}

// Atomically rewrite the file.
tmpPath := filePath + ".tmp"
content := strings.Join(keep, "\n")
if len(keep) > 0 {
content += "\n"
}
if err := os.WriteFile(tmpPath, []byte(content), 0644); err != nil {
return fmt.Errorf("write truncated session: %w", err)
}
if err := os.Rename(tmpPath, filePath); err != nil {
return fmt.Errorf("rename truncated session: %w", err)
}

// Reopen for append so subsequent writes go to the correct file.
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("reopen session for append: %w", err)
}
r.file = f
r.resuming = true
return nil
}

// Close flushes and closes the underlying file. Safe to call multiple times.
// If no messages were ever recorded the file is never created.
func (r *Recorder) Close() {
Expand Down
69 changes: 69 additions & 0 deletions internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,9 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("POST /api/providers", s.handleAddProvider)
mux.HandleFunc("DELETE /api/providers/{id}", s.handleDeleteProvider)

// History management.
mux.HandleFunc("POST /api/history/truncate", s.handleTruncateHistory)

// Model state API — favorites & recent.
mux.HandleFunc("GET /api/model-state", s.handleGetModelState)
mux.HandleFunc("POST /api/model-state/favorite", s.handleToggleFavorite)
Expand Down Expand Up @@ -621,6 +624,72 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func (s *Server) handleTruncateHistory(w http.ResponseWriter, r *http.Request) {
if s.running.Load() {
writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is currently running"})
return
}

var req struct {
// BeforeUserMessage: keep all history entries that come before the
// Nth user message (0-indexed). Everything from that user message
// onward is discarded. Pass 0 to clear everything.
BeforeUserMessage int `json:"before_user_message"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}

// Capture the recorder reference under the lock but do file I/O outside
// so we don't block other goroutines.
s.mu.Lock()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
rec := s.recorder
sessionID := ""
if rec != nil {
sessionID = rec.UUID()
}
s.mu.Unlock()

// Persist first — if the file rewrite fails we abort without touching
// the in-memory history so state never diverges.
if rec != nil {
if err := rec.TruncateAtUserMessage(req.BeforeUserMessage); err != nil {
config.Logger().Printf("[truncate] rewrite session file failed: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to truncate session file"})
return
}
}

// Now truncate in-memory history.
s.mu.Lock()
truncAt := 0
if req.BeforeUserMessage > 0 {
userCount := 0
truncAt = len(s.history) // default: keep all
for i, msg := range s.history {
if msg.Role == schema.User {
if userCount == req.BeforeUserMessage {
truncAt = i
break
}
userCount++
}
}
}
if truncAt == 0 {
s.history = nil
} else {
s.history = s.history[:truncAt]
}
s.mu.Unlock()

writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"session_id": sessionID,
})
}

func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) {
// Parse optional request body for resume session ID.
var req struct {
Expand Down
10 changes: 9 additions & 1 deletion web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,15 @@ function startResize(e: MouseEvent) {
<!-- Timeline -->
<div v-else class="max-w-3xl mx-auto px-5 py-6 space-y-0.5">
<template v-for="item in store.timeline" :key="item.seq">
<ChatMessageVue v-if="item.kind === 'message'" :message="item.data" class="animate-fade-up" />
<ChatMessageVue
v-if="item.kind === 'message'"
:message="item.data"
:can-retry="item.data.role === 'assistant' && !store.isRunning"
:can-edit="item.data.role === 'user' && !store.isRunning"
class="animate-fade-up"
@retry="store.retryFromMessage(item.data.id)"
@edit="(text) => store.editAndResend(item.data.id, text)"
/>
<ToolCallCard v-else-if="item.kind === 'tool'" :tool="item.data" class="animate-fade-up" />
<ApprovalBanner v-else-if="item.kind === 'approval'" :approval="item.data" class="animate-fade-up" />
</template>
Expand Down
137 changes: 129 additions & 8 deletions web/src/components/ChatMessage.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,68 @@
<script setup lang="ts">
import { renderMarkdown } from '@/composables/markdown'
import type { ChatMessage } from '@/types/api'
import { ref, nextTick } from 'vue'

defineProps<{
const props = defineProps<{
message: ChatMessage
canRetry?: boolean
canEdit?: boolean
}>()

const emit = defineEmits<{
retry: []
edit: [newText: string]
}>()

const copied = ref(false)
const editing = ref(false)
const editText = ref('')
const editTextarea = ref<HTMLTextAreaElement | null>(null)

function copyContent() {
navigator.clipboard.writeText(props.message.content).then(() => {
copied.value = true
setTimeout(() => { copied.value = false }, 1500)
}).catch((err) => {
console.error('Failed to copy:', err)
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function startEdit() {
editText.value = props.message.content
editing.value = true
nextTick(() => {
editTextarea.value?.focus()
})
}

function confirmEdit() {
const text = editText.value.trim()
if (text) {
emit('edit', text)
}
editing.value = false
}

function cancelEdit() {
editing.value = false
}

function handleEditKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
confirmEdit()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEdit()
}
}
</script>

<template>
<div class="py-3 animate-fade-in">
<!-- Role label -->
<div class="py-3 animate-fade-in group/msg">
<!-- Role label + action buttons -->
<div class="flex items-center gap-2 mb-2">
<div
class="w-5 h-5 rounded flex items-center justify-center text-[9px] font-bold shrink-0"
Expand Down Expand Up @@ -38,7 +91,52 @@ defineProps<{
>
{{ message.role === 'user' ? (message.source === 'wechat' ? 'WeChat' : 'You') : message.role === 'assistant' ? '[J]CODE' : 'System' }}
</span>

<!-- Action buttons: visible on hover or keyboard focus-within -->
<div class="flex items-center gap-0.5 ml-1 opacity-0 group-hover/msg:opacity-100 group-focus-within/msg:opacity-100 transition-opacity duration-150">
<!-- Copy button -->
<button
class="w-5 h-5 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer"
:title="copied ? 'Copied!' : 'Copy'"
@click="copyContent"
>
<svg v-if="!copied" class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
<svg v-else class="w-3 h-3 text-emerald-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</button>

<!-- Retry button (assistant messages) -->
<button
v-if="canRetry"
class="w-5 h-5 flex items-center justify-center rounded text-zinc-400 hover:text-amber-500 dark:hover:text-amber-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer"
title="Retry"
@click="emit('retry')"
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
</button>

<!-- Edit button (user messages) -->
<button
v-if="canEdit && !editing"
class="w-5 h-5 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer"
title="Edit"
@click="startEdit"
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4Z" />
</svg>
</button>
</div>
</div>

<!-- Images -->
<div v-if="message.images && message.images.length > 0" class="pl-7 mb-2 flex flex-wrap gap-2">
<img
Expand All @@ -49,10 +147,33 @@ defineProps<{
@click="($event.target as HTMLImageElement).classList.toggle('max-w-64'); ($event.target as HTMLImageElement).classList.toggle('max-w-full')"
/>
</div>
<!-- Content -->
<div
class="prose-chat pl-7"
v-html="renderMarkdown(message.content)"
/>

<!-- Content or Edit mode -->
<div v-if="!editing" class="prose-chat pl-7" v-html="renderMarkdown(message.content)" />

<!-- Inline edit mode -->
<div v-else class="pl-7">
<textarea
ref="editTextarea"
v-model="editText"
class="w-full min-h-20 max-h-80 resize-y rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 text-sm text-zinc-800 dark:text-zinc-200 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 focus:border-emerald-500 dark:focus:border-emerald-400 transition-colors"
@keydown="handleEditKeyDown"
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<div class="flex items-center gap-2 mt-2">
<button
class="px-3 py-1 text-xs font-medium rounded bg-emerald-500 hover:bg-emerald-600 text-white transition-colors cursor-pointer"
@click="confirmEdit"
>
Send
</button>
<button
class="px-3 py-1 text-xs font-medium rounded bg-zinc-100 hover:bg-zinc-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 text-zinc-600 dark:text-zinc-300 transition-colors cursor-pointer"
@click="cancelEdit"
>
Cancel
</button>
<span class="text-[10px] text-zinc-400">Enter to send · Shift+Enter for newline · Esc to cancel</span>
</div>
</div>
</div>
</template>
5 changes: 5 additions & 0 deletions web/src/composables/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
}

export const api = {
truncateHistory: (beforeUserMessage: number) =>
request<{ status: string; session_id: string }>('/api/history/truncate', {
method: 'POST',
body: JSON.stringify({ before_user_message: beforeUserMessage }),
}),
health: () =>
request<{ status: string; version: string; pwd: string; provider: string; model: string; mode: string; session_id: string; running: boolean; image_support?: boolean }>(
'/api/health',
Expand Down
Loading