Restart conversation support.
The idea here is to let the user restart the conversation, possibly with
a better prompt. This is a common manual workflow, and I'd like to make
it easier.
I hand wrote the agent.go stuff, but Sketch wrote the rest.
Co-Authored-By: sketch <hello@sketch.dev>
diff --git a/ant/ant.go b/ant/ant.go
index 69c17c2..8759883 100644
--- a/ant/ant.go
+++ b/ant/ant.go
@@ -464,6 +464,29 @@
}
}
+func (c *Convo) SubConvoWithHistory() *Convo {
+ id := newConvoID()
+ return &Convo{
+ Ctx: skribe.ContextWithAttr(c.Ctx, slog.String("convo_id", id), slog.String("parent_convo_id", c.ID)),
+ HTTPC: c.HTTPC,
+ URL: c.URL,
+ APIKey: c.APIKey,
+ Model: c.Model,
+ MaxTokens: c.MaxTokens,
+ PromptCaching: c.PromptCaching,
+ Parent: c,
+ // For convenience, sub-convo usage shares tool uses map with parent,
+ // all other fields separate, propagated in AddResponse
+ usage: newUsageWithSharedToolUses(c.usage),
+ mu: c.mu,
+ Listener: c.Listener,
+ ID: id,
+ // Do not copy Budget. Each budget is independent,
+ // and OverBudget checks whether any ancestor is over budget.
+ messages: slices.Clone(c.messages),
+ }
+}
+
// Depth reports how many "sub-conversations" deep this conversation is.
// That it, it walks up parents until it finds a root.
func (c *Convo) Depth() int {
@@ -664,6 +687,11 @@
return toolResults, nil
}
+// GetID returns the conversation ID
+func (c *Convo) GetID() string {
+ return c.ID
+}
+
func (c *Convo) CancelToolUse(toolUseID string, err error) error {
c.muToolUseCancel.Lock()
defer c.muToolUseCancel.Unlock()
diff --git a/cmd/sketch/main.go b/cmd/sketch/main.go
index 2720659..23654ab 100644
--- a/cmd/sketch/main.go
+++ b/cmd/sketch/main.go
@@ -257,6 +257,7 @@
OutsideHostname: *outsideHostname,
OutsideOS: *outsideOS,
OutsideWorkingDir: *outsideWorkingDir,
+ InDocker: inDocker,
}
agent := loop.NewAgent(agentConfig)
diff --git a/loop/agent.go b/loop/agent.go
index 497f048..b698d22 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -92,6 +92,15 @@
OutsideHostname() string
OutsideWorkingDir() string
GitOrigin() string
+
+ // RestartConversation resets the conversation history
+ RestartConversation(ctx context.Context, rev string, initialPrompt string) error
+ // SuggestReprompt suggests a re-prompt based on the current conversation.
+ SuggestReprompt(ctx context.Context) (string, error)
+ // IsInContainer returns true if the agent is running in a container
+ IsInContainer() bool
+ // FirstMessageIndex returns the index of the first message in the current conversation
+ FirstMessageIndex() int
}
type CodingAgentMessageType string
@@ -248,26 +257,29 @@
OverBudget() error
SendMessage(message ant.Message) (*ant.MessageResponse, error)
SendUserTextMessage(s string, otherContents ...ant.Content) (*ant.MessageResponse, error)
+ GetID() string
ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error)
ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error)
CancelToolUse(toolUseID string, cause error) error
+ SubConvoWithHistory() *ant.Convo
}
type Agent struct {
- convo ConvoInterface
- config AgentConfig // config for this agent
- workingDir string
- repoRoot string // workingDir may be a subdir of repoRoot
- url string
- lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
- initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
- gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
- ready chan struct{} // closed when the agent is initialized (only when under docker)
- startedAt time.Time
- originalBudget ant.Budget
- title string
- branchName string
- codereview *claudetool.CodeReviewer
+ convo ConvoInterface
+ config AgentConfig // config for this agent
+ workingDir string
+ repoRoot string // workingDir may be a subdir of repoRoot
+ url string
+ firstMessageIndex int // index of the first message in the current conversation
+ lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
+ initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
+ gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
+ ready chan struct{} // closed when the agent is initialized (only when under docker)
+ startedAt time.Time
+ originalBudget ant.Budget
+ title string
+ branchName string
+ codereview *claudetool.CodeReviewer
// Outside information
outsideHostname string
outsideOS string
@@ -379,6 +391,16 @@
return a.gitOrigin
}
+func (a *Agent) IsInContainer() bool {
+ return a.config.InDocker
+}
+
+func (a *Agent) FirstMessageIndex() int {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.firstMessageIndex
+}
+
// SetTitleBranch sets the title and branch name of the conversation.
func (a *Agent) SetTitleBranch(title, branchName string) {
a.mu.Lock()
@@ -531,6 +553,7 @@
SessionID string
ClientGOOS string
ClientGOARCH string
+ InDocker bool
UseAnthropicEdit bool
// Outside information
OutsideHostname string
@@ -1382,3 +1405,82 @@
}
return strings.TrimSpace(string(out))
}
+
+func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
+ cmd := exec.CommandContext(ctx, "git", "stash")
+ cmd.Dir = workingDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("git stash: %s: %v", out, err)
+ }
+ cmd = exec.CommandContext(ctx, "git", "fetch", "sketch-host")
+ cmd.Dir = workingDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("git fetch: %s: %w", out, err)
+ }
+ cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
+ cmd.Dir = workingDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
+ }
+ a.lastHEAD = revision
+ a.initialCommit = revision
+ return nil
+}
+
+func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
+ a.mu.Lock()
+ a.title = ""
+ a.firstMessageIndex = len(a.history)
+ a.convo = a.initConvo()
+ gitReset := func() error {
+ if a.config.InDocker && rev != "" {
+ err := a.initGitRevision(ctx, a.workingDir, rev)
+ if err != nil {
+ return err
+ }
+ } else if !a.config.InDocker && rev != "" {
+ return fmt.Errorf("Not resetting git repo when working outside of a container.")
+ }
+ return nil
+ }
+ err := gitReset()
+ a.mu.Unlock()
+ if err != nil {
+ a.pushToOutbox(a.config.Context, errorMessage(err))
+ }
+
+ a.pushToOutbox(a.config.Context, AgentMessage{
+ Type: AgentMessageType, Content: "Conversation restarted.",
+ })
+ if initialPrompt != "" {
+ a.UserMessage(ctx, initialPrompt)
+ }
+ return nil
+}
+
+func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
+ msg := `The user has requested a suggestion for a re-prompt.
+
+ Given the current conversation thus far, suggest a re-prompt that would
+ capture the instructions and feedback so far, as well as any
+ research or other information that would be helpful in implementing
+ the task.
+
+ Reply with ONLY the reprompt text.
+ `
+ userMessage := ant.Message{
+ Role: "user",
+ Content: []ant.Content{{Type: "text", Text: msg}},
+ }
+ // By doing this in a subconversation, the agent doesn't call tools (because
+ // there aren't any), and there's not a concurrency risk with on-going other
+ // outstanding conversations.
+ convo := a.convo.SubConvoWithHistory()
+ resp, err := convo.SendMessage(userMessage)
+ if err != nil {
+ a.pushToOutbox(ctx, errorMessage(err))
+ return "", err
+ }
+ textContent := collectTextContent(resp)
+ return textContent, nil
+}
diff --git a/loop/agent_test.go b/loop/agent_test.go
index 61b057c..bde0d20 100644
--- a/loop/agent_test.go
+++ b/loop/agent_test.go
@@ -267,6 +267,8 @@
cumulativeUsageFunc func() ant.CumulativeUsage
resetBudgetFunc func(ant.Budget)
overBudgetFunc func() error
+ getIDFunc func() string
+ subConvoWithHistoryFunc func() *ant.Convo
}
func (m *MockConvoInterface) SendMessage(message ant.Message) (*ant.MessageResponse, error) {
@@ -324,6 +326,20 @@
return nil
}
+func (m *MockConvoInterface) GetID() string {
+ if m.getIDFunc != nil {
+ return m.getIDFunc()
+ }
+ return "mock-convo-id"
+}
+
+func (m *MockConvoInterface) SubConvoWithHistory() *ant.Convo {
+ if m.subConvoWithHistoryFunc != nil {
+ return m.subConvoWithHistoryFunc()
+ }
+ return nil
+}
+
// TestAgentProcessTurnWithNilResponseNilError tests the scenario where Agent.processTurn receives
// a nil value for initialResp and nil error from processUserMessage.
// This test verifies that the implementation properly handles this edge case.
diff --git a/loop/mocks.go b/loop/mocks.go
index 264c6bc..7e05070 100644
--- a/loop/mocks.go
+++ b/loop/mocks.go
@@ -192,6 +192,16 @@
return nil
}
+func (m *MockConvo) GetID() string {
+ m.recordCall("GetID")
+ return "mock-conversation-id"
+}
+
+func (m *MockConvo) SubConvoWithHistory() *ant.Convo {
+ m.recordCall("SubConvoWithHistory")
+ return nil
+}
+
func (m *MockConvo) ResetBudget(_ ant.Budget) {
m.recordCall("ResetBudget")
}
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index c1254e2..c19f806 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -64,6 +64,8 @@
SessionID string `json:"session_id"`
SSHAvailable bool `json:"ssh_available"`
SSHError string `json:"ssh_error,omitempty"`
+ InContainer bool `json:"in_container"`
+ FirstMessageIndex int `json:"first_message_index"`
OutsideHostname string `json:"outside_hostname,omitempty"`
InsideHostname string `json:"inside_hostname,omitempty"`
@@ -388,6 +390,8 @@
SessionID: agent.SessionID(),
SSHAvailable: s.sshAvailable,
SSHError: s.sshError,
+ InContainer: agent.IsInContainer(),
+ FirstMessageIndex: agent.FirstMessageIndex(),
}
// Create a JSON encoder with indentation for pretty-printing
@@ -451,6 +455,94 @@
w.Write(data)
})
+ // Handler for POST /restart - restarts the conversation
+ s.mux.HandleFunc("/restart", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Parse the request body
+ var requestBody struct {
+ Revision string `json:"revision"`
+ InitialPrompt string `json:"initial_prompt"`
+ }
+
+ decoder := json.NewDecoder(r.Body)
+ if err := decoder.Decode(&requestBody); err != nil {
+ http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ defer r.Body.Close()
+
+ // Call the restart method
+ err := agent.RestartConversation(r.Context(), requestBody.Revision, requestBody.InitialPrompt)
+ if err != nil {
+ http.Error(w, "Failed to restart conversation: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Return success response
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "restarted"})
+ })
+
+ // Handler for /suggest-reprompt - suggests a reprompt based on conversation history
+ // Handler for /commit-description - returns the description of a git commit
+ s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Get the revision parameter
+ revision := r.URL.Query().Get("revision")
+ if revision == "" {
+ http.Error(w, "Missing revision parameter", http.StatusBadRequest)
+ return
+ }
+
+ // Run git command to get commit description
+ cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
+ // Use the working directory from the agent
+ cmd.Dir = s.agent.WorkingDir()
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Prepare the response
+ resp := map[string]string{
+ "description": strings.TrimSpace(string(output)),
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
+ }
+ })
+
+ // Handler for /suggest-reprompt - suggests a reprompt based on conversation history
+ s.mux.HandleFunc("/suggest-reprompt", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Call the suggest reprompt method
+ suggestedPrompt, err := agent.SuggestReprompt(r.Context())
+ if err != nil {
+ http.Error(w, "Failed to suggest reprompt: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Return success response
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"prompt": suggestedPrompt})
+ })
+
// Handler for POST /chat
s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -774,9 +866,11 @@
mux := http.NewServeMux()
mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ // TODO: pid is not as useful as "outside pid"
fmt.Fprintf(w, `<!doctype html>
<html><head><title>sketch debug</title></head><body>
<h1>sketch debug</h1>
+ pid %d
<ul>
<li><a href="/debug/pprof/cmdline">pprof/cmdline</a></li>
<li><a href="/debug/pprof/profile">pprof/profile</a></li>
@@ -787,7 +881,7 @@
</ul>
</body>
</html>
- `)
+ `, os.Getpid())
})
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
diff --git a/webui/src/fixtures/dummy.ts b/webui/src/fixtures/dummy.ts
index 4a40337..1972b09 100644
--- a/webui/src/fixtures/dummy.ts
+++ b/webui/src/fixtures/dummy.ts
@@ -373,4 +373,6 @@
inside_working_dir: "/Users/pokey/src/spaghetti",
outstanding_llm_calls: 0,
outstanding_tool_calls: [],
+ in_container: true,
+ first_message_index: 0,
};
diff --git a/webui/src/types.ts b/webui/src/types.ts
index e8bb746..b9451b7 100644
--- a/webui/src/types.ts
+++ b/webui/src/types.ts
@@ -74,6 +74,8 @@
session_id: string;
ssh_available: boolean;
ssh_error?: string;
+ in_container: boolean;
+ first_message_index: number;
outside_hostname?: string;
inside_hostname?: string;
outside_os?: string;
diff --git a/webui/src/web-components/sketch-app-shell.ts b/webui/src/web-components/sketch-app-shell.ts
index 3ee27f6..1cd1a0b 100644
--- a/webui/src/web-components/sketch-app-shell.ts
+++ b/webui/src/web-components/sketch-app-shell.ts
@@ -13,6 +13,7 @@
import "./sketch-terminal";
import "./sketch-timeline";
import "./sketch-view-mode-select";
+import "./sketch-restart-modal";
import { createRef, ref } from "lit/directives/ref.js";
@@ -229,6 +230,27 @@
margin-right: 50px;
}
+ .restart-button {
+ background: #2196f3;
+ color: white;
+ border: none;
+ padding: 4px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ margin-right: 5px;
+ }
+
+ .restart-button:hover {
+ background-color: #0b7dda;
+ }
+
+ .restart-button:disabled {
+ background-color: #ccc;
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+
.refresh-button {
background: #4caf50;
color: white;
@@ -304,8 +326,14 @@
outstanding_tool_calls: [],
session_id: "",
ssh_available: false,
+ ssh_error: "",
+ in_container: false,
+ first_message_index: 0,
};
+ @state()
+ private restartModalOpen = false;
+
// Mutation observer to detect when new messages are added
private mutationObserver: MutationObserver | null = null;
@@ -706,10 +734,6 @@
this.updateDocumentTitle();
}
- /**
- * Handle stop button click
- * Sends a request to the server to stop the current operation
- */
// Update last commit information when new messages arrive
private updateLastCommitInfo(newMessages: AgentMessage[]): void {
if (!newMessages || newMessages.length === 0) return;
@@ -782,6 +806,14 @@
}
}
+ openRestartModal() {
+ this.restartModalOpen = true;
+ }
+
+ handleRestartModalClose() {
+ this.restartModalOpen = false;
+ }
+
async _sendChat(e: CustomEvent) {
console.log("app shell: _sendChat", e);
const message = e.detail.message?.trim();
@@ -867,10 +899,14 @@
<div class="refresh-control">
<button
- id="stopButton"
- class="refresh-button stop-button"
- @click="${this._handleStopClick}"
+ id="restartButton"
+ class="restart-button"
+ ?disabled=${this.containerState.message_count === 0}
+ @click=${this.openRestartModal}
>
+ Restart
+ </button>
+ <button id="stopButton" class="refresh-button stop-button">
Stop
</button>
@@ -939,6 +975,13 @@
<div id="chat-input">
<sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
</div>
+
+ <sketch-restart-modal
+ ?open=${this.restartModalOpen}
+ @close=${this.handleRestartModalClose}
+ .containerState=${this.containerState}
+ .messages=${this.messages}
+ ></sketch-restart-modal>
`;
}
@@ -956,6 +999,27 @@
50,
);
+ // Setup stop button
+ const stopButton = this.renderRoot?.querySelector(
+ "#stopButton",
+ ) as HTMLButtonElement;
+ stopButton?.addEventListener("click", async () => {
+ try {
+ const response = await fetch("/cancel", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ reason: "User clicked stop button" }),
+ });
+ if (!response.ok) {
+ console.error("Failed to cancel:", await response.text());
+ }
+ } catch (error) {
+ console.error("Error cancelling operation:", error);
+ }
+ });
+
const pollToggleCheckbox = this.renderRoot?.querySelector(
"#pollToggle",
) as HTMLInputElement;
diff --git a/webui/src/web-components/sketch-container-status.test.ts b/webui/src/web-components/sketch-container-status.test.ts
index db43a6d..f8dc685 100644
--- a/webui/src/web-components/sketch-container-status.test.ts
+++ b/webui/src/web-components/sketch-container-status.test.ts
@@ -24,6 +24,8 @@
outstanding_tool_calls: [],
session_id: "test-session-id",
ssh_available: false,
+ in_container: true,
+ first_message_index: 0,
};
test("render props", async ({ mount }) => {
diff --git a/webui/src/web-components/sketch-restart-modal.ts b/webui/src/web-components/sketch-restart-modal.ts
new file mode 100644
index 0000000..b5cf7e4
--- /dev/null
+++ b/webui/src/web-components/sketch-restart-modal.ts
@@ -0,0 +1,907 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { AgentMessage, State } from "../types";
+
+@customElement("sketch-restart-modal")
+export class SketchRestartModal extends LitElement {
+ @property({ type: Boolean })
+ open = false;
+
+ @property({ attribute: false })
+ containerState: State | null = null;
+
+ @property({ attribute: false })
+ messages: AgentMessage[] = [];
+
+ @state()
+ private restartType: "initial" | "current" | "other" = "current";
+
+ @state()
+ private customRevision = "";
+
+ @state()
+ private promptOption: "suggested" | "original" | "new" = "suggested";
+
+ @state()
+ private commitDescriptions: Record<string, string> = {
+ current: "",
+ initial: "",
+ };
+
+ @state()
+ private suggestedPrompt = "";
+
+ @state()
+ private originalPrompt = "";
+
+ @state()
+ private newPrompt = "";
+
+ @state()
+ private isLoading = false;
+
+ @state()
+ private isSuggestionLoading = false;
+
+ @state()
+ private isOriginalPromptLoading = false;
+
+ @state()
+ private errorMessage = "";
+
+ static styles = css`
+ :host {
+ display: block;
+ font-family:
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+ }
+
+ .modal-description {
+ margin: 0 0 20px 0;
+ color: #555;
+ font-size: 14px;
+ line-height: 1.5;
+ }
+
+ .container-message {
+ margin: 10px 0;
+ padding: 8px 12px;
+ background-color: #f8f9fa;
+ border-left: 4px solid #6c757d;
+ color: #555;
+ font-size: 14px;
+ line-height: 1.5;
+ border-radius: 4px;
+ }
+
+ .modal-backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 1000;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s ease-in-out;
+ }
+
+ .modal-backdrop.open {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ .modal-container {
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ width: 600px;
+ max-width: 90%;
+ max-height: 90vh;
+ overflow-y: auto;
+ padding: 20px;
+ }
+
+ .modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ border-bottom: 1px solid #eee;
+ padding-bottom: 10px;
+ }
+
+ .modal-title {
+ font-size: 18px;
+ font-weight: 600;
+ margin: 0;
+ }
+
+ .close-button {
+ background: none;
+ border: none;
+ font-size: 18px;
+ cursor: pointer;
+ color: #666;
+ }
+
+ .close-button:hover {
+ color: #333;
+ }
+
+ .form-group {
+ margin-bottom: 16px;
+ }
+
+ .horizontal-radio-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ margin-bottom: 16px;
+ }
+
+ .revision-option {
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ padding: 8px 12px;
+ min-width: 180px;
+ cursor: pointer;
+ transition: all 0.2s;
+ }
+
+ .revision-option label {
+ font-size: 0.9em;
+ font-weight: bold;
+ }
+
+ .revision-option:hover {
+ border-color: #2196f3;
+ background-color: #f5f9ff;
+ }
+
+ .revision-option.selected {
+ border-color: #2196f3;
+ background-color: #e3f2fd;
+ }
+
+ .revision-option input[type="radio"] {
+ margin-right: 8px;
+ }
+
+ .revision-description {
+ margin-top: 4px;
+ color: #666;
+ font-size: 0.8em;
+ font-family: monospace;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 200px;
+ }
+
+ .form-group label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 500;
+ }
+
+ .radio-group {
+ margin-bottom: 8px;
+ }
+
+ .radio-option {
+ display: flex;
+ align-items: center;
+ margin-bottom: 8px;
+ }
+
+ .radio-option input {
+ margin-right: 8px;
+ }
+
+ .custom-revision {
+ margin-left: 24px;
+ margin-top: 8px;
+ width: calc(100% - 24px);
+ padding: 6px 8px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ display: block;
+ }
+
+ .prompt-container {
+ position: relative;
+ margin-top: 16px;
+ }
+
+ .prompt-textarea {
+ display: block;
+ box-sizing: border-box;
+ width: 100%;
+ min-height: 120px;
+ padding: 8px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-family: inherit;
+ resize: vertical;
+ background-color: white;
+ color: #333;
+ }
+
+ .prompt-textarea.disabled {
+ background-color: #f5f5f5;
+ color: #999;
+ cursor: not-allowed;
+ }
+
+ .actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ margin-top: 20px;
+ }
+
+ .btn {
+ padding: 8px 16px;
+ border-radius: 4px;
+ font-weight: 500;
+ cursor: pointer;
+ border: none;
+ }
+
+ .btn-cancel {
+ background: #f2f2f2;
+ color: #333;
+ }
+
+ .btn-restart {
+ background: #4caf50;
+ color: white;
+ }
+
+ .btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ .error-message {
+ color: #e53935;
+ margin-top: 16px;
+ font-size: 14px;
+ }
+
+ .loading-indicator {
+ display: inline-block;
+ margin-right: 8px;
+ margin-left: 8px;
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ border-top-color: white;
+ animation: spin 1s linear infinite;
+ }
+
+ .prompt-container .loading-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(255, 255, 255, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 2;
+ border-radius: 4px;
+ }
+
+ .prompt-container .loading-overlay .loading-indicator {
+ width: 24px;
+ height: 24px;
+ border: 2px solid rgba(0, 0, 0, 0.1);
+ border-top-color: #2196f3;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ }
+
+ .radio-option .loading-indicator {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border: 1.5px solid rgba(0, 0, 0, 0.2);
+ border-top-color: #2196f3;
+ vertical-align: middle;
+ margin-left: 8px;
+ }
+
+ .radio-option .status-ready {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ color: #4caf50;
+ margin-left: 8px;
+ font-weight: bold;
+ vertical-align: middle;
+ }
+
+ @keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+ }
+ `;
+
+ constructor() {
+ super();
+ this.handleEscape = this.handleEscape.bind(this);
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ document.addEventListener("keydown", this.handleEscape);
+ }
+
+ // Handle keyboard navigation
+ firstUpdated() {
+ if (this.shadowRoot) {
+ // Set up proper tab navigation by ensuring all focusable elements are included
+ const focusableElements =
+ this.shadowRoot.querySelectorAll('[tabindex="0"]');
+ if (focusableElements.length > 0) {
+ // Set initial focus when modal opens
+ (focusableElements[0] as HTMLElement).focus();
+ }
+ }
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ document.removeEventListener("keydown", this.handleEscape);
+ }
+
+ handleEscape(e: KeyboardEvent) {
+ if (e.key === "Escape" && this.open) {
+ this.closeModal();
+ }
+ }
+
+ closeModal() {
+ this.open = false;
+ this.dispatchEvent(new CustomEvent("close"));
+ }
+
+ async loadCommitDescription(
+ revision: string,
+ target: "current" | "initial" | "other" = "other",
+ ) {
+ try {
+ const response = await fetch(
+ `./commit-description?revision=${encodeURIComponent(revision)}`,
+ );
+ if (!response.ok) {
+ throw new Error(
+ `Failed to load commit description: ${response.statusText}`,
+ );
+ }
+
+ const data = await response.json();
+
+ if (target === "other") {
+ // For custom revisions, update the customRevision directly
+ this.customRevision = `${revision.slice(0, 8)} - ${data.description}`;
+ } else {
+ // For known targets, update the commitDescriptions object
+ this.commitDescriptions = {
+ ...this.commitDescriptions,
+ [target]: data.description,
+ };
+ }
+ } catch (error) {
+ console.error(`Error loading commit description for ${revision}:`, error);
+ }
+ }
+
+ handleRevisionChange(e?: Event) {
+ if (e) {
+ const target = e.target as HTMLInputElement;
+ this.restartType = target.value as "initial" | "current" | "other";
+ }
+
+ // Load commit description for any custom revision if needed
+ if (
+ this.restartType === "other" &&
+ this.customRevision &&
+ !this.customRevision.includes(" - ")
+ ) {
+ this.loadCommitDescription(this.customRevision, "other");
+ }
+ }
+
+ handleCustomRevisionChange(e: Event) {
+ const target = e.target as HTMLInputElement;
+ this.customRevision = target.value;
+ }
+
+ handlePromptOptionChange(e: Event) {
+ const target = e.target as HTMLInputElement;
+ this.promptOption = target.value as "suggested" | "original" | "new";
+
+ if (
+ this.promptOption === "suggested" &&
+ !this.isSuggestionLoading &&
+ this.suggestedPrompt === ""
+ ) {
+ this.loadSuggestedPrompt();
+ } else if (
+ this.promptOption === "original" &&
+ !this.isOriginalPromptLoading &&
+ this.originalPrompt === ""
+ ) {
+ this.loadOriginalPrompt();
+ }
+ }
+
+ handleSuggestedPromptChange(e: Event) {
+ const target = e.target as HTMLTextAreaElement;
+ this.suggestedPrompt = target.value;
+ }
+
+ handleOriginalPromptChange(e: Event) {
+ const target = e.target as HTMLTextAreaElement;
+ this.originalPrompt = target.value;
+ }
+
+ handleNewPromptChange(e: Event) {
+ const target = e.target as HTMLTextAreaElement;
+ this.newPrompt = target.value;
+ }
+
+ async loadSuggestedPrompt() {
+ try {
+ this.isSuggestionLoading = true;
+ this.errorMessage = "";
+
+ const response = await fetch("./suggest-reprompt");
+ if (!response.ok) {
+ throw new Error(`Failed to load suggestion: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ this.suggestedPrompt = data.prompt;
+ } catch (error) {
+ console.error("Error loading suggested prompt:", error);
+ this.errorMessage =
+ error instanceof Error ? error.message : "Failed to load suggestion";
+ } finally {
+ this.isSuggestionLoading = false;
+ }
+ }
+
+ async loadOriginalPrompt() {
+ try {
+ this.isOriginalPromptLoading = true;
+ this.errorMessage = "";
+
+ // Get the first message index from the container state
+ const firstMessageIndex = this.containerState?.first_message_index || 0;
+
+ // Find the first user message after the first_message_index
+ let firstUserMessage = "";
+
+ if (this.messages && this.messages.length > 0) {
+ for (const msg of this.messages) {
+ // Only look at messages starting from first_message_index
+ if (msg.idx >= firstMessageIndex && msg.type === "user") {
+ // Simply use the content field if it's a string
+ if (typeof msg.content === "string") {
+ firstUserMessage = msg.content;
+ } else {
+ // Fallback to stringifying content field for any other type
+ firstUserMessage = JSON.stringify(msg.content);
+ }
+ break;
+ }
+ }
+ }
+
+ if (!firstUserMessage) {
+ console.warn("Could not find original user message", this.messages);
+ }
+
+ this.originalPrompt = firstUserMessage;
+ } catch (error) {
+ console.error("Error loading original prompt:", error);
+ this.errorMessage =
+ error instanceof Error
+ ? error.message
+ : "Failed to load original prompt";
+ } finally {
+ this.isOriginalPromptLoading = false;
+ }
+ }
+
+ async handleRestart() {
+ try {
+ this.isLoading = true;
+ this.errorMessage = "";
+
+ let revision = "";
+ switch (this.restartType) {
+ case "initial":
+ // We'll leave revision empty for this case, backend will handle it
+ break;
+ case "current":
+ // We'll leave revision empty for this case too, backend will use current HEAD
+ break;
+ case "other":
+ revision = this.customRevision.trim();
+ if (!revision) {
+ throw new Error("Please enter a valid revision");
+ }
+ break;
+ }
+
+ // Determine which prompt to use based on selected option
+ let initialPrompt = "";
+ switch (this.promptOption) {
+ case "suggested":
+ initialPrompt = this.suggestedPrompt.trim();
+ if (!initialPrompt && this.isSuggestionLoading) {
+ throw new Error(
+ "Suggested prompt is still loading. Please wait or choose another option.",
+ );
+ }
+ break;
+ case "original":
+ initialPrompt = this.originalPrompt.trim();
+ if (!initialPrompt && this.isOriginalPromptLoading) {
+ throw new Error(
+ "Original prompt is still loading. Please wait or choose another option.",
+ );
+ }
+ break;
+ case "new":
+ initialPrompt = this.newPrompt.trim();
+ break;
+ }
+
+ // Validate we have a prompt when needed
+ if (!initialPrompt && this.promptOption !== "new") {
+ throw new Error(
+ "Unable to get prompt text. Please enter a new prompt or try again.",
+ );
+ }
+
+ const response = await fetch("./restart", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ revision: revision,
+ initial_prompt: initialPrompt,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`Failed to restart: ${errorText}`);
+ }
+
+ // Reload the page after successful restart
+ window.location.reload();
+ } catch (error) {
+ console.error("Error restarting conversation:", error);
+ this.errorMessage =
+ error instanceof Error
+ ? error.message
+ : "Failed to restart conversation";
+ } finally {
+ this.isLoading = false;
+ }
+ }
+
+ updated(changedProperties: Map<string, any>) {
+ if (changedProperties.has("open") && this.open) {
+ // Reset form when opening
+ this.restartType = "current";
+ this.customRevision = "";
+ this.promptOption = "suggested";
+ this.suggestedPrompt = "";
+ this.originalPrompt = "";
+ this.newPrompt = "";
+ this.errorMessage = "";
+ this.commitDescriptions = {
+ current: "",
+ initial: "",
+ };
+
+ // Pre-load all available prompts and commit descriptions in the background
+ setTimeout(() => {
+ // Load prompt data
+ this.loadSuggestedPrompt();
+ this.loadOriginalPrompt();
+
+ // Load commit descriptions
+ this.loadCommitDescription("HEAD", "current");
+ if (this.containerState?.initial_commit) {
+ this.loadCommitDescription(
+ this.containerState.initial_commit,
+ "initial",
+ );
+ }
+
+ // Set focus to the first radio button for keyboard navigation
+ if (this.shadowRoot) {
+ const firstInput = this.shadowRoot.querySelector(
+ 'input[type="radio"]',
+ ) as HTMLElement;
+ if (firstInput) {
+ firstInput.focus();
+ }
+ }
+ }, 0);
+ }
+ }
+
+ render() {
+ const inContainer = this.containerState?.in_container || false;
+
+ return html`
+ <div class="modal-backdrop ${this.open ? "open" : ""}">
+ <div class="modal-container">
+ <div class="modal-header">
+ <h2 class="modal-title">Restart Conversation</h2>
+ <button class="close-button" @click=${this.closeModal}>×</button>
+ </div>
+
+ <p class="modal-description">
+ Restarting the conversation hides the history from the agent. If you
+ want the agent to take a different direction, restart with a new
+ prompt.
+ </p>
+
+ <div class="form-group">
+ <label>Reset to which revision?</label>
+ <div class="horizontal-radio-group">
+ <div
+ class="revision-option ${this.restartType === "current"
+ ? "selected"
+ : ""}"
+ @click=${() => {
+ this.restartType = "current";
+ this.handleRevisionChange();
+ }}
+ >
+ <input
+ type="radio"
+ id="restart-current"
+ name="restart-type"
+ value="current"
+ ?checked=${this.restartType === "current"}
+ @change=${this.handleRevisionChange}
+ tabindex="0"
+ />
+ <label for="restart-current">Current HEAD</label>
+ ${this.commitDescriptions.current
+ ? html`<div class="revision-description">
+ ${this.commitDescriptions.current}
+ </div>`
+ : ""}
+ </div>
+
+ ${inContainer
+ ? html`
+ <div
+ class="revision-option ${this.restartType === "initial"
+ ? "selected"
+ : ""}"
+ @click=${() => {
+ this.restartType = "initial";
+ this.handleRevisionChange();
+ }}
+ >
+ <input
+ type="radio"
+ id="restart-initial"
+ name="restart-type"
+ value="initial"
+ ?checked=${this.restartType === "initial"}
+ @change=${this.handleRevisionChange}
+ tabindex="0"
+ />
+ <label for="restart-initial">Initial commit</label>
+ ${this.commitDescriptions.initial
+ ? html`<div class="revision-description">
+ ${this.commitDescriptions.initial}
+ </div>`
+ : ""}
+ </div>
+
+ <div
+ class="revision-option ${this.restartType === "other"
+ ? "selected"
+ : ""}"
+ @click=${() => {
+ this.restartType = "other";
+ this.handleRevisionChange();
+ }}
+ >
+ <input
+ type="radio"
+ id="restart-other"
+ name="restart-type"
+ value="other"
+ ?checked=${this.restartType === "other"}
+ @change=${this.handleRevisionChange}
+ tabindex="0"
+ />
+ <label for="restart-other">Other revision</label>
+ </div>
+ `
+ : html`
+ <div class="container-message">
+ Additional revision options are not available because
+ Sketch is not running inside a container.
+ </div>
+ `}
+ </div>
+
+ ${this.restartType === "other" && inContainer
+ ? html`
+ <input
+ type="text"
+ class="custom-revision"
+ placeholder="Enter commit hash"
+ .value=${this.customRevision}
+ @input=${this.handleCustomRevisionChange}
+ tabindex="0"
+ />
+ `
+ : ""}
+ </div>
+
+ <div class="form-group">
+ <label>Prompt options:</label>
+ <div class="radio-group">
+ <div class="radio-option">
+ <input
+ type="radio"
+ id="prompt-suggested"
+ name="prompt-type"
+ value="suggested"
+ ?checked=${this.promptOption === "suggested"}
+ @change=${this.handlePromptOptionChange}
+ tabindex="0"
+ />
+ <label for="prompt-suggested">
+ Suggest prompt based on history (default)
+ </label>
+ </div>
+
+ <div class="radio-option">
+ <input
+ type="radio"
+ id="prompt-original"
+ name="prompt-type"
+ value="original"
+ ?checked=${this.promptOption === "original"}
+ @change=${this.handlePromptOptionChange}
+ tabindex="0"
+ />
+ <label for="prompt-original"> Original prompt </label>
+ </div>
+
+ <div class="radio-option">
+ <input
+ type="radio"
+ id="prompt-new"
+ name="prompt-type"
+ value="new"
+ ?checked=${this.promptOption === "new"}
+ @change=${this.handlePromptOptionChange}
+ tabindex="0"
+ />
+ <label for="prompt-new">New prompt</label>
+ </div>
+ </div>
+ </div>
+
+ <div class="prompt-container">
+ ${this.promptOption === "suggested"
+ ? html`
+ <textarea
+ class="prompt-textarea${this.isSuggestionLoading
+ ? " disabled"
+ : ""}"
+ placeholder="Loading suggested prompt..."
+ .value=${this.suggestedPrompt}
+ ?disabled=${this.isSuggestionLoading}
+ @input=${this.handleSuggestedPromptChange}
+ tabindex="0"
+ ></textarea>
+ ${this.isSuggestionLoading
+ ? html`
+ <div class="loading-overlay">
+ <div class="loading-indicator"></div>
+ </div>
+ `
+ : ""}
+ `
+ : this.promptOption === "original"
+ ? html`
+ <textarea
+ class="prompt-textarea${this.isOriginalPromptLoading
+ ? " disabled"
+ : ""}"
+ placeholder="Loading original prompt..."
+ .value=${this.originalPrompt}
+ ?disabled=${this.isOriginalPromptLoading}
+ @input=${this.handleOriginalPromptChange}
+ tabindex="0"
+ ></textarea>
+ ${this.isOriginalPromptLoading
+ ? html`
+ <div class="loading-overlay">
+ <div class="loading-indicator"></div>
+ </div>
+ `
+ : ""}
+ `
+ : html`
+ <textarea
+ class="prompt-textarea"
+ placeholder="Enter a new prompt..."
+ .value=${this.newPrompt}
+ @input=${this.handleNewPromptChange}
+ tabindex="0"
+ ></textarea>
+ `}
+ </div>
+
+ ${this.errorMessage
+ ? html` <div class="error-message">${this.errorMessage}</div> `
+ : ""}
+
+ <div class="actions">
+ <button
+ class="btn btn-cancel"
+ @click=${this.closeModal}
+ ?disabled=${this.isLoading}
+ tabindex="0"
+ >
+ Cancel
+ </button>
+ <button
+ class="btn btn-restart"
+ @click=${this.handleRestart}
+ ?disabled=${this.isLoading}
+ tabindex="0"
+ >
+ ${this.isLoading
+ ? html`<span class="loading-indicator"></span>`
+ : ""}
+ Restart
+ </button>
+ </div>
+ </div>
+ </div>
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-restart-modal": SketchRestartModal;
+ }
+}