feat: add file-backed prompt attachments (#1367)

This commit is contained in:
Carlos Florêncio
2026-06-14 21:40:02 +01:00
committed by GitHub
parent cf3469b4d1
commit 010948767a
44 changed files with 1147 additions and 96 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ Manage and run tasks in parallel. Orchestrate agents. Review changes. Ship value
<img src="docs/screenshots/readme-intro.gif" alt="Kandev Demo"> <img src="docs/screenshots/readme-intro.gif" alt="Kandev Demo">
</p> </p>
[See all screenshots](docs/screenshots.md) [See desktop and mobile screenshots](docs/screenshots.md)
## What ## What
+5 -4
View File
@@ -216,10 +216,11 @@ func convertToLifecycleAttachments(attachments []v1.MessageAttachment) []lifecyc
result := make([]lifecycle.MessageAttachment, len(attachments)) result := make([]lifecycle.MessageAttachment, len(attachments))
for i, att := range attachments { for i, att := range attachments {
result[i] = lifecycle.MessageAttachment{ result[i] = lifecycle.MessageAttachment{
Type: att.Type, Type: att.Type,
Data: att.Data, Data: att.Data,
MimeType: att.MimeType, MimeType: att.MimeType,
Name: att.Name, Name: att.Name,
DeliveryMode: att.DeliveryMode,
} }
} }
return result return result
@@ -411,10 +411,11 @@ func convertAttachments(attachments []MessageAttachment) []v1.MessageAttachment
result := make([]v1.MessageAttachment, 0, len(attachments)) result := make([]v1.MessageAttachment, 0, len(attachments))
for _, att := range attachments { for _, att := range attachments {
result = append(result, v1.MessageAttachment{ result = append(result, v1.MessageAttachment{
Type: att.Type, Type: att.Type,
Data: att.Data, Data: att.Data,
MimeType: att.MimeType, MimeType: att.MimeType,
Name: att.Name, Name: att.Name,
DeliveryMode: att.DeliveryMode,
}) })
} }
return result return result
@@ -425,10 +425,11 @@ func (r *LaunchRequest) RepoSpecs() []RepoLaunchSpec {
// MessageAttachment represents an image or file attachment for agent prompts. // MessageAttachment represents an image or file attachment for agent prompts.
type MessageAttachment struct { type MessageAttachment struct {
Type string // "image", "audio", or "resource" Type string // "image", "audio", or "resource"
Data string // base64-encoded data Data string // base64-encoded data
MimeType string // MIME type MimeType string // MIME type
Name string // optional filename for resource attachments Name string // optional filename for resource attachments
DeliveryMode string // "prompt" (native/default) or "path"
} }
// CredentialsManager interface for credential retrieval // CredentialsManager interface for credential retrieval
@@ -77,32 +77,7 @@ func (a *Adapter) sendPrompt(ctx context.Context, message string, attachments []
zap.Int("context_length", len(pendingContext))) zap.Int("context_length", len(pendingContext)))
} }
// Build content blocks: text first, then images contentBlocks := a.buildPromptContentBlocks(finalMessage, attachments)
contentBlocks := []acp.ContentBlock{acp.TextBlock(finalMessage)}
// Add media attachments as typed content blocks
for _, att := range attachments {
switch att.Type {
case contentTypeImage:
contentBlocks = append(contentBlocks, acp.ImageBlock(att.Data, att.MimeType))
case contentTypeAudio:
contentBlocks = append(contentBlocks, acp.AudioBlock(att.Data, att.MimeType))
case contentTypeResource:
if a.capabilities.PromptCapabilities.EmbeddedContext {
contentBlocks = append(contentBlocks, buildResourceBlock(att))
} else {
// Agent doesn't support embedded resources — save to workspace and reference in text
saved, saveErr := a.attachMgr.SaveAttachments([]v1.MessageAttachment{att})
if saveErr != nil || len(saved) == 0 {
a.logger.Warn("failed to save attachment to workspace, falling back to resource block",
zap.String("name", att.Name), zap.Error(saveErr))
contentBlocks = append(contentBlocks, buildResourceBlock(att))
} else {
contentBlocks = append(contentBlocks, acp.TextBlock(shared.BuildAttachmentPrompt(saved)))
}
}
}
}
// Start prompt span — notification spans become children via getPromptTraceCtx() // Start prompt span — notification spans become children via getPromptTraceCtx()
traceCtx, promptSpan := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "prompt") traceCtx, promptSpan := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "prompt")
@@ -225,12 +200,63 @@ func (a *Adapter) sendPrompt(ctx context.Context, message string, attachments []
Usage: usage, Usage: usage,
}) })
// Clean up saved attachments — agent has finished reading them
a.attachMgr.Cleanup()
return nil return nil
} }
func (a *Adapter) buildPromptContentBlocks(message string, attachments []v1.MessageAttachment) []acp.ContentBlock {
contentBlocks := []acp.ContentBlock{acp.TextBlock(message)}
for _, att := range attachments {
if att.DeliveryMode == "path" {
saved, saveErr := a.attachMgr.SaveAttachments([]v1.MessageAttachment{att})
if saveErr != nil || len(saved) == 0 {
a.logger.Warn("failed to save path-mode attachment to workspace",
zap.String("name", att.Name), zap.Error(saveErr))
contentBlocks = append(contentBlocks, buildAttachmentFallbackBlock(att))
continue
}
contentBlocks = append(contentBlocks, acp.TextBlock(shared.BuildAttachmentPrompt(saved, true)))
continue
}
switch att.Type {
case contentTypeImage:
contentBlocks = append(contentBlocks, acp.ImageBlock(att.Data, att.MimeType))
case contentTypeAudio:
contentBlocks = append(contentBlocks, acp.AudioBlock(att.Data, att.MimeType))
case contentTypeResource:
if a.capabilities.PromptCapabilities.EmbeddedContext {
contentBlocks = append(contentBlocks, buildResourceBlock(att))
} else {
// Agent doesn't support embedded resources — save to workspace and reference in text.
saved, saveErr := a.attachMgr.SaveAttachments([]v1.MessageAttachment{att})
if saveErr != nil || len(saved) == 0 {
a.logger.Warn("failed to save attachment to workspace, falling back to resource block",
zap.String("name", att.Name), zap.Error(saveErr))
contentBlocks = append(contentBlocks, buildResourceBlock(att))
} else {
contentBlocks = append(contentBlocks, acp.TextBlock(shared.BuildAttachmentPrompt(saved, false)))
}
}
}
}
return contentBlocks
}
func buildAttachmentFallbackBlock(att v1.MessageAttachment) acp.ContentBlock {
switch att.Type {
case contentTypeImage:
return acp.ImageBlock(att.Data, att.MimeType)
case contentTypeAudio:
return acp.AudioBlock(att.Data, att.MimeType)
case contentTypeResource:
return buildResourceBlock(att)
default:
return acp.TextBlock("The user attached a file, but Kandev could not save it to the workspace.")
}
}
// fireWakeup is invoked by wakeupScheduler when a ScheduleWakeup timer // fireWakeup is invoked by wakeupScheduler when a ScheduleWakeup timer
// elapses. It issues a synthetic session/prompt so the upstream // elapses. It issues a synthetic session/prompt so the upstream
// @agentclientprotocol/claude-agent-acp bridge drains the SDK's queued wakeup // @agentclientprotocol/claude-agent-acp bridge drains the SDK's queued wakeup
@@ -1,10 +1,16 @@
package acp package acp
import ( import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing" "testing"
"github.com/coder/acp-go-sdk" acp "github.com/coder/acp-go-sdk"
"github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared"
"github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/agentctl/types/streams"
v1 "github.com/kandev/kandev/pkg/api/v1"
) )
// TestCancelActiveToolCalls_PreservesSubagentTask pins the fix for the // TestCancelActiveToolCalls_PreservesSubagentTask pins the fix for the
@@ -50,6 +56,124 @@ func TestCancelActiveToolCalls_PreservesSubagentTask(t *testing.T) {
} }
} }
func TestBuildPromptContentBlocks_PathModeAttachmentSavesWritableFile(t *testing.T) {
a := newTestAdapter()
t.Cleanup(func() { _ = a.Close() })
workDir := t.TempDir()
a.attachMgr = shared.NewAttachmentManager(workDir, a.logger.Zap())
a.attachMgr.SetSessionID("sess-path")
a.capabilities = acp.AgentCapabilities{
PromptCapabilities: acp.PromptCapabilities{
Image: true,
EmbeddedContext: true,
},
}
encoded := base64.StdEncoding.EncodeToString([]byte("remote bytes"))
blocks := a.buildPromptContentBlocks("inspect this", []v1.MessageAttachment{{
Type: "image",
Data: encoded,
MimeType: "image/png",
Name: "shot.png",
DeliveryMode: "path",
}})
if len(blocks) != 2 {
t.Fatalf("expected text prompt plus attachment path prompt, got %d blocks", len(blocks))
}
if blocks[1].Text == nil {
t.Fatalf("expected path-mode attachment to be sent as text, got %#v", blocks[1])
}
if blocks[1].Image != nil {
t.Fatalf("path-mode attachment should not be sent as an image block")
}
text := blocks[1].Text.Text
if !strings.Contains(text, ".kandev/attachments/sess-path/shot.png") {
t.Fatalf("attachment prompt did not include saved path: %q", text)
}
if !strings.Contains(text, "writable") {
t.Fatalf("attachment prompt did not include writable contract: %q", text)
}
savedPath := filepath.Join(workDir, ".kandev", "attachments", "sess-path", "shot.png")
data, err := os.ReadFile(savedPath)
if err != nil {
t.Fatalf("expected attachment to be written to workspace: %v", err)
}
if string(data) != "remote bytes" {
t.Fatalf("saved attachment = %q, want %q", string(data), "remote bytes")
}
}
func TestBuildPromptContentBlocks_PathModeSaveFailureFallsBackToNativeBlock(t *testing.T) {
a := newTestAdapter()
t.Cleanup(func() { _ = a.Close() })
a.attachMgr = shared.NewAttachmentManager("", a.logger.Zap())
a.attachMgr.SetSessionID("sess-path")
a.capabilities = acp.AgentCapabilities{
PromptCapabilities: acp.PromptCapabilities{
Image: true,
},
}
encoded := base64.StdEncoding.EncodeToString([]byte("image bytes"))
blocks := a.buildPromptContentBlocks("inspect this", []v1.MessageAttachment{{
Type: "image",
Data: encoded,
MimeType: "image/png",
Name: "shot.png",
DeliveryMode: "path",
}})
if len(blocks) != 2 {
t.Fatalf("expected text prompt plus native fallback block, got %d blocks", len(blocks))
}
if blocks[1].Image == nil {
t.Fatalf("expected path-mode save failure to fall back to an image block, got %#v", blocks[1])
}
if blocks[1].Text != nil {
t.Fatalf("fallback image should not be replaced by a text path prompt")
}
}
func TestBuildPromptContentBlocks_UnsupportedEmbeddedResourceSavesReadOnlyFile(t *testing.T) {
a := newTestAdapter()
t.Cleanup(func() { _ = a.Close() })
workDir := t.TempDir()
a.attachMgr = shared.NewAttachmentManager(workDir, a.logger.Zap())
a.attachMgr.SetSessionID("sess-resource")
a.capabilities = acp.AgentCapabilities{
PromptCapabilities: acp.PromptCapabilities{
EmbeddedContext: false,
},
}
encoded := base64.StdEncoding.EncodeToString([]byte("doc bytes"))
blocks := a.buildPromptContentBlocks("read this", []v1.MessageAttachment{{
Type: "resource",
Data: encoded,
MimeType: "application/pdf",
Name: "doc.pdf",
}})
if len(blocks) != 2 {
t.Fatalf("expected text prompt plus attachment path prompt, got %d blocks", len(blocks))
}
if blocks[1].Text == nil {
t.Fatalf("expected unsupported embedded resource to be sent as text path prompt, got %#v", blocks[1])
}
text := blocks[1].Text.Text
if !strings.Contains(text, ".kandev/attachments/sess-resource/doc.pdf") {
t.Fatalf("attachment prompt did not include saved path: %q", text)
}
if strings.Contains(text, "writable") || strings.Contains(text, "modify") {
t.Fatalf("fallback resource prompt should be read-only, got: %q", text)
}
}
// TestCancelActiveToolCalls_SubagentLateUpdate_LandsAsComplete drives the full // TestCancelActiveToolCalls_SubagentLateUpdate_LandsAsComplete drives the full
// race scenario end-to-end through the real adapter methods (no mocks): // race scenario end-to-end through the real adapter methods (no mocks):
// //
@@ -2,6 +2,7 @@ package shared
import ( import (
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -64,6 +65,7 @@ func (m *AttachmentManager) SaveAttachments(attachments []v1.MessageAttachment)
} }
var saved []SavedAttachment var saved []SavedAttachment
usedNames := make(map[string]bool, len(attachments))
for _, att := range attachments { for _, att := range attachments {
name := att.Name name := att.Name
if name == "" { if name == "" {
@@ -75,18 +77,18 @@ func (m *AttachmentManager) SaveAttachments(attachments []v1.MessageAttachment)
m.logger.Warn("skipping attachment with invalid name", zap.String("original_name", att.Name)) m.logger.Warn("skipping attachment with invalid name", zap.String("original_name", att.Name))
continue continue
} }
decoded, err := base64.StdEncoding.DecodeString(att.Data) decoded, err := base64.StdEncoding.DecodeString(att.Data)
if err != nil { if err != nil {
m.logger.Warn("failed to decode attachment", zap.String("name", name), zap.Error(err)) m.logger.Warn("failed to decode attachment", zap.String("name", name), zap.Error(err))
continue continue
} }
absPath := filepath.Join(dir, name) name, absPath, err := writeUniqueAttachmentFile(dir, name, usedNames, decoded)
if err := os.WriteFile(absPath, decoded, 0o644); err != nil { if err != nil {
m.logger.Warn("failed to write attachment", zap.String("path", absPath), zap.Error(err)) m.logger.Warn("failed to write attachment", zap.String("path", absPath), zap.Error(err))
continue continue
} }
usedNames[name] = true
relPath := filepath.Join(".kandev", "attachments", m.sessionID, name) relPath := filepath.Join(".kandev", "attachments", m.sessionID, name)
saved = append(saved, SavedAttachment{ saved = append(saved, SavedAttachment{
@@ -117,7 +119,7 @@ func (m *AttachmentManager) Cleanup() {
// BuildAttachmentPrompt generates prompt text referencing saved attachment files. // BuildAttachmentPrompt generates prompt text referencing saved attachment files.
// Used by adapters that don't support native multimodal content. // Used by adapters that don't support native multimodal content.
func BuildAttachmentPrompt(saved []SavedAttachment) string { func BuildAttachmentPrompt(saved []SavedAttachment, writable bool) string {
if len(saved) == 0 { if len(saved) == 0 {
return "" return ""
} }
@@ -125,17 +127,72 @@ func BuildAttachmentPrompt(saved []SavedAttachment) string {
var sb strings.Builder var sb strings.Builder
if len(saved) == 1 { if len(saved) == 1 {
s := saved[0] s := saved[0]
fmt.Fprintf(&sb, "The user attached a file: %s (saved to %s in the workspace). Use your file reading tools to access it.\n\n", s.Name, s.RelPath) name := sanitizePromptValue(s.Name)
} else { relPath := sanitizePromptValue(s.RelPath)
sb.WriteString("The user attached files that you should read and analyze:\n") if writable {
for _, s := range saved { fmt.Fprintf(&sb, "The user attached a writable file: %s (saved to %s in the workspace). Use your file reading tools to access or modify it.\n\n", name, relPath)
fmt.Fprintf(&sb, "- %s (saved to %s)\n", s.Name, s.RelPath) } else {
fmt.Fprintf(&sb, "The user attached a file: %s (saved to %s in the workspace). Use your file reading tools to access it.\n\n", name, relPath)
}
} else {
if writable {
sb.WriteString("The user attached writable files that you should read and analyze:\n")
} else {
sb.WriteString("The user attached files that you should read and analyze:\n")
}
for _, s := range saved {
fmt.Fprintf(&sb, "- %s (saved to %s)\n", sanitizePromptValue(s.Name), sanitizePromptValue(s.RelPath))
}
if writable {
sb.WriteString("\nUse your file reading tools to access or modify them.\n\n")
} else {
sb.WriteString("\nUse your file reading tools to access them.\n\n")
} }
sb.WriteString("\nUse your file reading tools to access them.\n\n")
} }
return sb.String() return sb.String()
} }
func writeUniqueAttachmentFile(dir, name string, used map[string]bool, data []byte) (string, string, error) {
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for i := 1; ; i++ {
candidate := name
if i > 1 {
candidate = fmt.Sprintf("%s-%d%s", base, i, ext)
}
if used[candidate] {
continue
}
absPath := filepath.Join(dir, candidate)
file, err := os.OpenFile(absPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if errors.Is(err, os.ErrExist) {
used[candidate] = true
continue
}
if err != nil {
return "", absPath, err
}
_, writeErr := file.Write(data)
closeErr := file.Close()
if writeErr != nil {
_ = os.Remove(absPath)
return "", absPath, writeErr
}
if closeErr != nil {
_ = os.Remove(absPath)
return "", absPath, closeErr
}
return candidate, absPath, nil
}
}
func sanitizePromptValue(value string) string {
replacer := strings.NewReplacer("\r", " ", "\n", " ", "<", "(", ">", ")", "`", "'")
return strings.TrimSpace(replacer.Replace(value))
}
// generateName creates a filename for attachments that don't have a Name field. // generateName creates a filename for attachments that don't have a Name field.
func (m *AttachmentManager) generateName(att v1.MessageAttachment) string { func (m *AttachmentManager) generateName(att v1.MessageAttachment) string {
ext := extensionFromMimeType(att.MimeType) ext := extensionFromMimeType(att.MimeType)
@@ -2,8 +2,10 @@ package shared
import ( import (
"encoding/base64" "encoding/base64"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"testing" "testing"
v1 "github.com/kandev/kandev/pkg/api/v1" v1 "github.com/kandev/kandev/pkg/api/v1"
@@ -172,6 +174,157 @@ func TestSaveAttachments_MultipleAttachments(t *testing.T) {
} }
} }
func TestSaveAttachments_DuplicateNamesDoNotClobber(t *testing.T) {
workDir := t.TempDir()
mgr := NewAttachmentManager(workDir, testLogger())
mgr.SetSessionID("sess-dupe")
first := base64.StdEncoding.EncodeToString([]byte("first"))
second := base64.StdEncoding.EncodeToString([]byte("second"))
third := base64.StdEncoding.EncodeToString([]byte("third"))
saved, err := mgr.SaveAttachments([]v1.MessageAttachment{
{Type: "resource", Data: first, MimeType: "text/plain", Name: "report.txt"},
{Type: "resource", Data: second, MimeType: "text/plain", Name: "report.txt"},
{Type: "resource", Data: third, MimeType: "text/plain", Name: "report.txt"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(saved) != 3 {
t.Fatalf("expected 3 saved, got %d", len(saved))
}
wantNames := []string{"report.txt", "report-2.txt", "report-3.txt"}
wantContents := []string{"first", "second", "third"}
for i := range saved {
if saved[i].Name != wantNames[i] {
t.Errorf("saved[%d].Name = %q, want %q", i, saved[i].Name, wantNames[i])
}
data, err := os.ReadFile(saved[i].AbsPath)
if err != nil {
t.Fatalf("failed to read saved[%d]: %v", i, err)
}
if string(data) != wantContents[i] {
t.Errorf("saved[%d] content = %q, want %q", i, string(data), wantContents[i])
}
}
}
func TestSaveAttachments_DuplicateNamesAcrossCallsDoNotClobber(t *testing.T) {
workDir := t.TempDir()
mgr := NewAttachmentManager(workDir, testLogger())
mgr.SetSessionID("sess-dupe-calls")
first := base64.StdEncoding.EncodeToString([]byte("first"))
second := base64.StdEncoding.EncodeToString([]byte("second"))
firstSaved, err := mgr.SaveAttachments([]v1.MessageAttachment{
{Type: "resource", Data: first, MimeType: "text/plain", Name: "report.txt"},
})
if err != nil {
t.Fatalf("unexpected first save error: %v", err)
}
secondSaved, err := mgr.SaveAttachments([]v1.MessageAttachment{
{Type: "resource", Data: second, MimeType: "text/plain", Name: "report.txt"},
})
if err != nil {
t.Fatalf("unexpected second save error: %v", err)
}
if firstSaved[0].Name != "report.txt" {
t.Fatalf("first name = %q, want report.txt", firstSaved[0].Name)
}
if secondSaved[0].Name != "report-2.txt" {
t.Fatalf("second name = %q, want report-2.txt", secondSaved[0].Name)
}
firstData, err := os.ReadFile(firstSaved[0].AbsPath)
if err != nil {
t.Fatalf("failed to read first file: %v", err)
}
secondData, err := os.ReadFile(secondSaved[0].AbsPath)
if err != nil {
t.Fatalf("failed to read second file: %v", err)
}
if string(firstData) != "first" {
t.Errorf("first content = %q, want first", string(firstData))
}
if string(secondData) != "second" {
t.Errorf("second content = %q, want second", string(secondData))
}
}
func TestSaveAttachments_ConcurrentDuplicateNamesDoNotClobber(t *testing.T) {
workDir := t.TempDir()
const workers = 20
var wg sync.WaitGroup
savedCh := make(chan SavedAttachment, workers)
errCh := make(chan error, workers)
for i := 0; i < workers; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
mgr := NewAttachmentManager(workDir, testLogger())
mgr.SetSessionID("sess-concurrent")
content := fmt.Sprintf("payload-%02d", i)
saved, err := mgr.SaveAttachments([]v1.MessageAttachment{
{
Type: "resource",
Data: base64.StdEncoding.EncodeToString([]byte(content)),
MimeType: "text/plain",
Name: "report.txt",
},
})
if err != nil {
errCh <- err
return
}
if len(saved) != 1 {
errCh <- fmt.Errorf("saved count = %d, want 1", len(saved))
return
}
savedCh <- saved[0]
}()
}
wg.Wait()
close(savedCh)
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("unexpected save error: %v", err)
}
}
names := make(map[string]bool, workers)
contents := make(map[string]bool, workers)
for saved := range savedCh {
if names[saved.Name] {
t.Fatalf("duplicate saved name %q", saved.Name)
}
names[saved.Name] = true
data, err := os.ReadFile(saved.AbsPath)
if err != nil {
t.Fatalf("failed to read %s: %v", saved.AbsPath, err)
}
contents[string(data)] = true
}
if len(names) != workers {
t.Fatalf("saved unique names = %d, want %d", len(names), workers)
}
if len(contents) != workers {
t.Fatalf("saved unique contents = %d, want %d", len(contents), workers)
}
}
func TestSaveAttachments_NoName_GeneratesFromMime(t *testing.T) { func TestSaveAttachments_NoName_GeneratesFromMime(t *testing.T) {
workDir := t.TempDir() workDir := t.TempDir()
mgr := NewAttachmentManager(workDir, testLogger()) mgr := NewAttachmentManager(workDir, testLogger())
@@ -303,27 +456,48 @@ func TestCleanup_SessionIsolation(t *testing.T) {
} }
func TestBuildAttachmentPrompt_Empty(t *testing.T) { func TestBuildAttachmentPrompt_Empty(t *testing.T) {
result := BuildAttachmentPrompt(nil) result := BuildAttachmentPrompt(nil, true)
if result != "" { if result != "" {
t.Errorf("expected empty string, got %q", result) t.Errorf("expected empty string, got %q", result)
} }
} }
func TestBuildAttachmentPrompt_SingleFile(t *testing.T) { func TestBuildAttachmentPrompt_SingleWritableFile(t *testing.T) {
saved := []SavedAttachment{ saved := []SavedAttachment{
{RelPath: ".kandev/attachments/s1/report.pdf", Name: "report.pdf"}, {RelPath: ".kandev/attachments/s1/report.pdf", Name: "report.pdf"},
} }
result := BuildAttachmentPrompt(saved) result := BuildAttachmentPrompt(saved, true)
if result == "" { if result == "" {
t.Fatal("expected non-empty result") t.Fatal("expected non-empty result")
} }
// Should mention the file name and path
if !contains(result, "report.pdf") { if !contains(result, "report.pdf") {
t.Errorf("result should contain filename, got: %q", result) t.Errorf("result should contain filename, got: %q", result)
} }
if !contains(result, ".kandev/attachments/s1/report.pdf") { if !contains(result, ".kandev/attachments/s1/report.pdf") {
t.Errorf("result should contain path, got: %q", result) t.Errorf("result should contain path, got: %q", result)
} }
if !contains(result, "writable") {
t.Errorf("result should tell the agent the file is writable, got: %q", result)
}
}
func TestBuildAttachmentPrompt_SingleReadOnlyFile(t *testing.T) {
saved := []SavedAttachment{
{RelPath: ".kandev/attachments/s1/report.pdf", Name: "report.pdf"},
}
result := BuildAttachmentPrompt(saved, false)
if result == "" {
t.Fatal("expected non-empty result")
}
if contains(result, "writable") {
t.Errorf("read-only result should not call the file writable, got: %q", result)
}
if contains(result, "modify") {
t.Errorf("read-only result should not tell the agent to modify the file, got: %q", result)
}
if !contains(result, "access it") {
t.Errorf("read-only result should tell the agent to access the file, got: %q", result)
}
} }
func TestBuildAttachmentPrompt_MultipleFiles(t *testing.T) { func TestBuildAttachmentPrompt_MultipleFiles(t *testing.T) {
@@ -331,7 +505,7 @@ func TestBuildAttachmentPrompt_MultipleFiles(t *testing.T) {
{RelPath: ".kandev/attachments/s1/a.pdf", Name: "a.pdf"}, {RelPath: ".kandev/attachments/s1/a.pdf", Name: "a.pdf"},
{RelPath: ".kandev/attachments/s1/b.png", Name: "b.png"}, {RelPath: ".kandev/attachments/s1/b.png", Name: "b.png"},
} }
result := BuildAttachmentPrompt(saved) result := BuildAttachmentPrompt(saved, true)
if !contains(result, "a.pdf") { if !contains(result, "a.pdf") {
t.Errorf("result should contain a.pdf, got: %q", result) t.Errorf("result should contain a.pdf, got: %q", result)
} }
@@ -340,6 +514,25 @@ func TestBuildAttachmentPrompt_MultipleFiles(t *testing.T) {
} }
} }
func TestBuildAttachmentPrompt_SanitizesPromptValues(t *testing.T) {
saved := []SavedAttachment{
{
RelPath: ".kandev/attachments/s1/<bad>\npath`v2`.pdf",
Name: "report<one>\nplease`now`.pdf",
},
}
result := BuildAttachmentPrompt(saved, true)
if contains(result, "<") || contains(result, ">") || contains(result, "`") || contains(result, "\nplease") {
t.Errorf("result contains unsanitized prompt values: %q", result)
}
if !contains(result, "report(one) please'now'.pdf") {
t.Errorf("result should contain sanitized filename, got: %q", result)
}
if !contains(result, ".kandev/attachments/s1/(bad) path'v2'.pdf") {
t.Errorf("result should contain sanitized path, got: %q", result)
}
}
func TestExtensionFromMimeType(t *testing.T) { func TestExtensionFromMimeType(t *testing.T) {
tests := []struct { tests := []struct {
mimeType string mimeType string
@@ -354,9 +354,11 @@ func (s *Service) executeQueuedMessage(callerSessionID string, queuedMsg *messag
attachments := make([]v1.MessageAttachment, len(queuedMsg.Attachments)) attachments := make([]v1.MessageAttachment, len(queuedMsg.Attachments))
for i, att := range queuedMsg.Attachments { for i, att := range queuedMsg.Attachments {
attachments[i] = v1.MessageAttachment{ attachments[i] = v1.MessageAttachment{
Type: att.Type, Type: att.Type,
Data: att.Data, Data: att.Data,
MimeType: att.MimeType, MimeType: att.MimeType,
Name: att.Name,
DeliveryMode: att.DeliveryMode,
} }
} }
@@ -1415,9 +1415,11 @@ func (s *Service) takeAndMergeHandoffMessage(ctx context.Context, sessionID, bas
attachments = make([]v1.MessageAttachment, 0, len(msg.Attachments)) attachments = make([]v1.MessageAttachment, 0, len(msg.Attachments))
for _, a := range msg.Attachments { for _, a := range msg.Attachments {
attachments = append(attachments, v1.MessageAttachment{ attachments = append(attachments, v1.MessageAttachment{
Type: a.Type, Type: a.Type,
Data: a.Data, Data: a.Data,
MimeType: a.MimeType, MimeType: a.MimeType,
Name: a.Name,
DeliveryMode: a.DeliveryMode,
}) })
} }
} }
@@ -1495,9 +1497,11 @@ func toQueuedAttachments(attachments []v1.MessageAttachment) []messagequeue.Mess
queued := make([]messagequeue.MessageAttachment, 0, len(attachments)) queued := make([]messagequeue.MessageAttachment, 0, len(attachments))
for _, attachment := range attachments { for _, attachment := range attachments {
queued = append(queued, messagequeue.MessageAttachment{ queued = append(queued, messagequeue.MessageAttachment{
Type: attachment.Type, Type: attachment.Type,
Data: attachment.Data, Data: attachment.Data,
MimeType: attachment.MimeType, MimeType: attachment.MimeType,
Name: attachment.Name,
DeliveryMode: attachment.DeliveryMode,
}) })
} }
return queued return queued
@@ -100,6 +100,10 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w
if req.Content == "" && len(req.Attachments) == 0 { if req.Content == "" && len(req.Attachments) == 0 {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content or attachments are required", nil) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content or attachments are required", nil)
} }
if invalid := firstInvalidDeliveryMode(req.Attachments); invalid >= 0 {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "attachment delivery_mode must be prompt or path",
map[string]interface{}{"attachment_index": invalid})
}
if req.UserID == messagequeue.QueuedByAgent { if req.UserID == messagequeue.QueuedByAgent {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "user_id may not impersonate the agent identity", nil) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "user_id may not impersonate the agent identity", nil)
} }
@@ -233,6 +237,10 @@ func (h *QueueHandlers) wsUpdateMessage(ctx context.Context, msg *ws.Message) (*
if req.Content == "" && len(req.Attachments) == 0 { if req.Content == "" && len(req.Attachments) == 0 {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content or attachments are required", nil) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content or attachments are required", nil)
} }
if invalid := firstInvalidDeliveryMode(req.Attachments); invalid >= 0 {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "attachment delivery_mode must be prompt or path",
map[string]interface{}{"attachment_index": invalid})
}
// Reject any client-supplied identity that would impersonate the agent. // Reject any client-supplied identity that would impersonate the agent.
// Without this guard a hostile WS client could send user_id="agent" to // Without this guard a hostile WS client could send user_id="agent" to
@@ -260,6 +268,15 @@ func (h *QueueHandlers) wsUpdateMessage(ctx context.Context, msg *ws.Message) (*
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{fieldEntryID: req.EntryID}) return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{fieldEntryID: req.EntryID})
} }
func firstInvalidDeliveryMode(attachments []messagequeue.MessageAttachment) int {
for i, att := range attachments {
if att.DeliveryMode != "" && att.DeliveryMode != "prompt" && att.DeliveryMode != "path" {
return i
}
}
return -1
}
type wsRemoveEntryRequest struct { type wsRemoveEntryRequest struct {
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
EntryID string `json:"entry_id"` EntryID string `json:"entry_id"`
@@ -151,6 +151,41 @@ func TestWsQueueMessage(t *testing.T) {
}) })
} }
func TestFirstInvalidDeliveryMode(t *testing.T) {
tests := []struct {
name string
attachments []messagequeue.MessageAttachment
want int
}{
{name: "empty list", attachments: nil, want: -1},
{
name: "valid modes",
attachments: []messagequeue.MessageAttachment{
{DeliveryMode: ""},
{DeliveryMode: "prompt"},
{DeliveryMode: "path"},
},
want: -1,
},
{
name: "first invalid mode",
attachments: []messagequeue.MessageAttachment{
{DeliveryMode: "prompt"},
{DeliveryMode: "inline"},
{DeliveryMode: "path"},
},
want: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := firstInvalidDeliveryMode(tt.attachments)
assert.Equal(t, tt.want, got)
})
}
}
func TestWsCancelAll(t *testing.T) { func TestWsCancelAll(t *testing.T) {
t.Run("clears the queue", func(t *testing.T) { t.Run("clears the queue", func(t *testing.T) {
handlers, svc := setupQueueHandlers(t) handlers, svc := setupQueueHandlers(t)
@@ -51,9 +51,11 @@ type QueuedMessage struct {
// MessageAttachment represents an attachment (image) in a queued message. // MessageAttachment represents an attachment (image) in a queued message.
type MessageAttachment struct { type MessageAttachment struct {
Type string `json:"type"` Type string `json:"type"`
Data string `json:"data"` Data string `json:"data"`
MimeType string `json:"mime_type"` MimeType string `json:"mime_type"`
Name string `json:"name,omitempty"`
DeliveryMode string `json:"delivery_mode,omitempty"`
} }
// QueueStatus is the per-session view returned to clients: full ordered list of // QueueStatus is the per-session view returned to clients: full ordered list of
@@ -298,6 +298,9 @@ func validateAddMessageRequest(req wsAddMessageRequest) string {
if req.Content == "" && len(req.Attachments) == 0 { if req.Content == "" && len(req.Attachments) == 0 {
return "content or attachments are required" return "content or attachments are required"
} }
if err := validateAttachments(req.Attachments); err != nil {
return err.Error()
}
return "" return ""
} }
@@ -461,6 +461,9 @@ func validateAttachments(items []v1.MessageAttachment) error {
if len(a.Data) > maxAttachmentDataBytes { if len(a.Data) > maxAttachmentDataBytes {
return fmt.Errorf("attachment[%d] data exceeds size limit", i) return fmt.Errorf("attachment[%d] data exceeds size limit", i)
} }
if !a.HasValidDeliveryMode() {
return fmt.Errorf("attachment[%d] delivery_mode must be prompt or path", i)
}
totalSize += len(a.Data) totalSize += len(a.Data)
} }
if totalSize > maxAttachmentDataBytes { if totalSize > maxAttachmentDataBytes {
@@ -19,6 +19,7 @@ import (
"github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/internal/task/service"
v1 "github.com/kandev/kandev/pkg/api/v1"
) )
// captureOrchestrator records every LaunchSession request so tests can assert // captureOrchestrator records every LaunchSession request so tests can assert
@@ -185,6 +186,40 @@ func TestHTTPCreateTask_ProjectIDReachesOfficePath(t *testing.T) {
assert.Equal(t, "wf-office", repo.captured.WorkflowID, "office workflow should be auto-resolved") assert.Equal(t, "wf-office", repo.captured.WorkflowID, "office workflow should be auto-resolved")
} }
func TestValidateAttachments_DeliveryMode(t *testing.T) {
base := v1.MessageAttachment{
Type: "resource",
MimeType: "text/plain",
Data: "dGVzdA==",
}
tests := []struct {
name string
deliveryMode string
wantErr string
}{
{name: "empty uses default", deliveryMode: ""},
{name: "prompt is valid", deliveryMode: "prompt"},
{name: "path is valid", deliveryMode: "path"},
{name: "inline is rejected", deliveryMode: "inline", wantErr: "delivery_mode must be prompt or path"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
attachment := base
attachment.DeliveryMode = tt.deliveryMode
err := validateAttachments([]v1.MessageAttachment{attachment})
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
func newTestLogger(t *testing.T) *logger.Logger { func newTestLogger(t *testing.T) *logger.Logger {
t.Helper() t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{ log, err := logger.NewLogger(logger.LoggingConfig{
+9 -4
View File
@@ -138,10 +138,15 @@ type TaskEvent struct {
// Type determines the ACP content block: "image" → ImageBlock, "audio" → AudioBlock, // Type determines the ACP content block: "image" → ImageBlock, "audio" → AudioBlock,
// "resource" → ResourceBlock (text or blob based on MIME type). // "resource" → ResourceBlock (text or blob based on MIME type).
type MessageAttachment struct { type MessageAttachment struct {
Type string `json:"type"` // "image", "audio", "resource" Type string `json:"type"` // "image", "audio", "resource"
Data string `json:"data,omitempty"` // Base64-encoded data Data string `json:"data,omitempty"` // Base64-encoded data
MimeType string `json:"mime_type,omitempty"` // MIME type (e.g., "image/png") MimeType string `json:"mime_type,omitempty"` // MIME type (e.g., "image/png")
Name string `json:"name,omitempty"` // Display name (e.g., filename) Name string `json:"name,omitempty"` // Display name (e.g., filename)
DeliveryMode string `json:"delivery_mode,omitempty"` // "prompt" (native/default) or "path"
}
func (a MessageAttachment) HasValidDeliveryMode() bool {
return a.DeliveryMode == "" || a.DeliveryMode == "prompt" || a.DeliveryMode == "path"
} }
// ContextFileMeta represents a context file reference attached to a message // ContextFileMeta represents a context file reference attached to a message
+25
View File
@@ -0,0 +1,25 @@
package v1
import "testing"
func TestMessageAttachmentHasValidDeliveryMode(t *testing.T) {
tests := []struct {
name string
deliveryMode string
want bool
}{
{name: "empty defaults to prompt", deliveryMode: "", want: true},
{name: "prompt", deliveryMode: "prompt", want: true},
{name: "path", deliveryMode: "path", want: true},
{name: "invalid", deliveryMode: "inline", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := (MessageAttachment{DeliveryMode: tt.deliveryMode}).HasValidDeliveryMode()
if got != tt.want {
t.Fatalf("HasValidDeliveryMode() = %v, want %v", got, tt.want)
}
})
}
}
@@ -30,12 +30,19 @@ export function toMessageAttachments(
if (attachments.length === 0) return undefined; if (attachments.length === 0) return undefined;
return attachments.map((att) => return attachments.map((att) =>
att.isImage att.isImage
? { type: "image" as const, data: att.data, mime_type: att.mimeType } ? {
type: "image" as const,
data: att.data,
mime_type: att.mimeType,
name: att.fileName,
...(att.deliveryMode === "path" && { delivery_mode: "path" as const }),
}
: { : {
type: "resource" as const, type: "resource" as const,
data: att.data, data: att.data,
mime_type: att.mimeType, mime_type: att.mimeType,
name: att.fileName, name: att.fileName,
delivery_mode: "path" as const,
}, },
); );
} }
@@ -107,6 +107,36 @@ describe("FileRow truncation (regression: path overlaps diff stats in narrow pan
repositoryName: "frontend", repositoryName: "frontend",
}); });
}); });
it("opens image files in the file preview instead of the diff viewer", () => {
const onOpenDiff = vi.fn();
const onEditFile = vi.fn();
const { container } = render(
<TooltipProvider>
<ul>
<FileRow
file={{ ...baseFile, path: "docs/screenshots/mobile-chat.webp" }}
isPending={false}
onSelect={noopSelect}
onOpenDiff={onOpenDiff}
onStage={noop}
onUnstage={noop}
onDiscard={noop}
onEditFile={onEditFile}
/>
</ul>
</TooltipProvider>,
);
const row = container.querySelector(
"[data-changes-file='docs/screenshots/mobile-chat.webp']",
) as HTMLElement | null;
expect(row).not.toBeNull();
fireEvent.click(row!);
expect(onEditFile).toHaveBeenCalledWith("docs/screenshots/mobile-chat.webp");
expect(onOpenDiff).not.toHaveBeenCalled();
});
}); });
describe("FileRow hover swap (stats <-> actions occupy same cell)", () => { describe("FileRow hover swap (stats <-> actions occupy same cell)", () => {
@@ -14,6 +14,7 @@ import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { LineStat } from "@/components/diff-stat"; import { LineStat } from "@/components/diff-stat";
import { FileIcon } from "@/components/ui/file-icon"; import { FileIcon } from "@/components/ui/file-icon";
import { getFileCategory } from "@/lib/utils/file-types";
import { FileStatusIcon } from "./file-status-icon"; import { FileStatusIcon } from "./file-status-icon";
import type { ChangedFile } from "./changes-panel-helpers"; import type { ChangedFile } from "./changes-panel-helpers";
import type { OpenDiffOptions } from "./changes-diff-target"; import type { OpenDiffOptions } from "./changes-diff-target";
@@ -74,6 +75,10 @@ export function FileRow({
if (e.button === 2) return; if (e.button === 2) return;
const consumed = onSelect?.(file.path, e); const consumed = onSelect?.(file.path, e);
if (!consumed) { if (!consumed) {
if (getFileCategory(file.path) === "image") {
onEditFile(file.path);
return;
}
onOpenDiff(file.path, { onOpenDiff(file.path, {
source: "uncommitted", source: "uncommitted",
repositoryName: file.repositoryName, repositoryName: file.repositoryName,
@@ -29,6 +29,7 @@ export type MessageAttachment = {
data: string; data: string;
mime_type: string; mime_type: string;
name?: string; name?: string;
delivery_mode?: "prompt" | "path";
}; };
export type ChatInputContainerHandle = { export type ChatInputContainerHandle = {
@@ -33,6 +33,7 @@ type ContextChipProps = {
preview?: ReactNode; preview?: ReactNode;
/** Data URL to render as a tiny thumbnail instead of the default icon */ /** Data URL to render as a tiny thumbnail instead of the default icon */
thumbnail?: string; thumbnail?: string;
leadingIcon?: ReactNode;
onClick?: () => void; onClick?: () => void;
onUnpin?: () => void; onUnpin?: () => void;
onRemove?: () => void; onRemove?: () => void;
@@ -44,23 +45,30 @@ export const ContextChip = memo(function ContextChip({
pinned, pinned,
preview, preview,
thumbnail, thumbnail,
leadingIcon,
onClick, onClick,
onUnpin, onUnpin,
onRemove, onRemove,
}: ContextChipProps) { }: ContextChipProps) {
const Icon = ICON_BY_KIND[kind]; const Icon = ICON_BY_KIND[kind];
let iconNode: ReactNode;
if (leadingIcon) {
iconNode = leadingIcon;
} else if (thumbnail) {
iconNode = (
// eslint-disable-next-line @next/next/no-img-element -- base64 thumbnail
<img src={thumbnail} alt="" className="h-3 w-3 shrink-0 rounded-sm object-cover" />
);
} else {
iconNode = <Icon className="h-3 w-3 shrink-0" />;
}
const chip = ( const chip = (
<div <div
className={`group flex items-center gap-1 px-2 py-0.5 text-xs text-muted-foreground bg-muted/50 rounded border border-border/50 ${onClick ? "cursor-pointer hover:bg-muted/80" : ""}`} className={`group flex items-center gap-1 px-2 py-0.5 text-xs text-muted-foreground bg-muted/50 rounded border border-border/50 ${onClick ? "cursor-pointer hover:bg-muted/80" : ""}`}
onClick={onClick} onClick={onClick}
> >
{thumbnail ? ( {iconNode}
// eslint-disable-next-line @next/next/no-img-element -- base64 thumbnail
<img src={thumbnail} alt="" className="h-3 w-3 shrink-0 rounded-sm object-cover" />
) : (
<Icon className="h-3 w-3 shrink-0" />
)}
<span className="truncate max-w-[120px]">{label}</span> <span className="truncate max-w-[120px]">{label}</span>
{pinned && onUnpin && ( {pinned && onUnpin && (
<button <button
@@ -1,6 +1,8 @@
"use client"; "use client";
import { memo, useState, useCallback } from "react"; import { memo, useState, useCallback } from "react";
import { IconFile, IconPhoto } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Dialog, DialogContent } from "@kandev/ui/dialog"; import { Dialog, DialogContent } from "@kandev/ui/dialog";
import type { ImageContextItem } from "@/lib/types/context"; import type { ImageContextItem } from "@/lib/types/context";
import { import {
@@ -12,6 +14,12 @@ import { ContextChip } from "./context-chip";
export const ImageItem = memo(function ImageItem({ item }: { item: ImageContextItem }) { export const ImageItem = memo(function ImageItem({ item }: { item: ImageContextItem }) {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const previewSrc = item.attachment.preview; const previewSrc = item.attachment.preview;
const deliveryMode = item.attachment.deliveryMode;
const deliveryDescription =
deliveryMode === "path"
? "Upload into the workspace so the agent can read or edit the file."
: "Send as prompt context for visual understanding. The agent will not get a file path.";
let leadingIcon;
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
setDialogOpen(true); setDialogOpen(true);
@@ -21,15 +29,74 @@ export const ImageItem = memo(function ImageItem({ item }: { item: ImageContextI
<div className="space-y-1.5"> <div className="space-y-1.5">
{/* eslint-disable-next-line @next/next/no-img-element -- base64 preview */} {/* eslint-disable-next-line @next/next/no-img-element -- base64 preview */}
<img src={previewSrc} alt="Preview" className="max-w-full max-h-48 rounded object-contain" /> <img src={previewSrc} alt="Preview" className="max-w-full max-h-48 rounded object-contain" />
{item.onDeliveryModeChange && (
<div className="space-y-1.5">
<div
className="flex items-center gap-1"
role="group"
aria-label="Attachment delivery mode"
>
<Button
type="button"
size="sm"
variant={deliveryMode === "prompt" ? "default" : "outline"}
className="h-6 px-2 text-xs"
data-testid="attachment-delivery-prompt"
data-selected={deliveryMode === "prompt" ? "true" : "false"}
aria-pressed={deliveryMode === "prompt"}
onClick={(event) => {
event.stopPropagation();
item.onDeliveryModeChange?.("prompt");
}}
>
Prompt
</Button>
<Button
type="button"
size="sm"
variant={deliveryMode === "path" ? "default" : "outline"}
className="h-6 px-2 text-xs"
data-testid="attachment-delivery-path"
data-selected={deliveryMode === "path" ? "true" : "false"}
aria-pressed={deliveryMode === "path"}
onClick={(event) => {
event.stopPropagation();
item.onDeliveryModeChange?.("path");
}}
>
File
</Button>
</div>
<p className="text-[11px] leading-snug text-muted-foreground">{deliveryDescription}</p>
</div>
)}
</div> </div>
) : undefined; ) : undefined;
if (deliveryMode === "path") {
leadingIcon = (
<span className="relative h-3 w-3 shrink-0" aria-hidden="true">
<IconFile className="h-3 w-3 text-muted-foreground" />
{previewSrc && (
// eslint-disable-next-line @next/next/no-img-element -- base64 thumbnail
<img
src={previewSrc}
alt=""
className="absolute -right-1 -bottom-1 h-2 w-2 rounded-[2px] border border-background object-cover"
/>
)}
</span>
);
} else if (!previewSrc) {
leadingIcon = <IconPhoto className="h-3 w-3 shrink-0" />;
}
return ( return (
<> <>
<ContextChip <ContextChip
kind="image" kind="image"
label={item.label} label={item.label}
thumbnail={previewSrc} thumbnail={deliveryMode === "prompt" ? previewSrc : undefined}
leadingIcon={leadingIcon}
preview={preview} preview={preview}
onClick={previewSrc ? handleClick : undefined} onClick={previewSrc ? handleClick : undefined}
onRemove={item.onRemove} onRemove={item.onRemove}
@@ -42,6 +42,7 @@ describe("processFile in insecure context (HTTP, no crypto.randomUUID)", () => {
expect(attachment!.id).toMatch(UUID_V4_REGEX); expect(attachment!.id).toMatch(UUID_V4_REGEX);
expect(attachment!.isImage).toBe(false); expect(attachment!.isImage).toBe(false);
expect(attachment!.fileName).toBe("notes.txt"); expect(attachment!.fileName).toBe("notes.txt");
expect(attachment!.deliveryMode).toBe("path");
}); });
it("assigns a fallback UUID to image attachments when crypto.randomUUID is unavailable", async () => { it("assigns a fallback UUID to image attachments when crypto.randomUUID is unavailable", async () => {
@@ -56,6 +57,7 @@ describe("processFile in insecure context (HTTP, no crypto.randomUUID)", () => {
expect(attachment).not.toBeNull(); expect(attachment).not.toBeNull();
expect(attachment!.id).toMatch(UUID_V4_REGEX); expect(attachment!.id).toMatch(UUID_V4_REGEX);
expect(attachment!.isImage).toBe(true); expect(attachment!.isImage).toBe(true);
expect(attachment!.deliveryMode).toBe("prompt");
expect(attachment!.preview).toMatch(/^data:image\/png;base64,/); expect(attachment!.preview).toMatch(/^data:image\/png;base64,/);
}); });
@@ -13,6 +13,7 @@ export type FileAttachment = {
size: number; // File size in bytes size: number; // File size in bytes
preview?: string; // Data URL for image preview (only for images) preview?: string; // Data URL for image preview (only for images)
isImage: boolean; // Whether this is a previewable image isImage: boolean; // Whether this is a previewable image
deliveryMode: "prompt" | "path"; // Native prompt block or writable executor file path
}; };
export const PREVIEWABLE_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; export const PREVIEWABLE_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
@@ -79,6 +80,7 @@ export function processFile(file: File): Promise<FileAttachment | null> {
size: file.size, size: file.size,
preview: dataUrl, preview: dataUrl,
isImage: true, isImage: true,
deliveryMode: "prompt",
}); });
}; };
img.onerror = () => resolve(null); img.onerror = () => resolve(null);
@@ -91,6 +93,7 @@ export function processFile(file: File): Promise<FileAttachment | null> {
fileName: file.name, fileName: file.name,
size: file.size, size: file.size,
isImage: false, isImage: false,
deliveryMode: "path",
}); });
} }
}; };
@@ -52,8 +52,20 @@ function collectComments(
function toMessageAttachments(attachments: FileAttachment[]): MessageAttachment[] { function toMessageAttachments(attachments: FileAttachment[]): MessageAttachment[] {
return attachments.map((att) => return attachments.map((att) =>
att.isImage att.isImage
? { type: "image" as const, data: att.data, mime_type: att.mimeType } ? {
: { type: "resource" as const, data: att.data, mime_type: att.mimeType, name: att.fileName }, type: "image" as const,
data: att.data,
mime_type: att.mimeType,
name: att.fileName,
...(att.deliveryMode === "path" && { delivery_mode: "path" as const }),
}
: {
type: "resource" as const,
data: att.data,
mime_type: att.mimeType,
name: att.fileName,
delivery_mode: "path" as const,
},
); );
} }
@@ -117,7 +129,19 @@ function useAttachments(sessionId: string | null) {
); );
const handleRemoveAttachment = useCallback((id: string) => { const handleRemoveAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((att) => att.id !== id)); setAttachments((prev) => {
const next = prev.filter((att) => att.id !== id);
attachmentsRef.current = next;
return next;
});
}, []);
const handleDeliveryModeChange = useCallback((id: string, deliveryMode: "prompt" | "path") => {
setAttachments((prev) => {
const next = prev.map((att) => (att.id === id ? { ...att, deliveryMode } : att));
attachmentsRef.current = next;
return next;
});
}, []); }, []);
const getAttachments = useCallback( const getAttachments = useCallback(
@@ -131,6 +155,7 @@ function useAttachments(sessionId: string | null) {
setAttachments, setAttachments,
addFiles, addFiles,
handleRemoveAttachment, handleRemoveAttachment,
handleDeliveryModeChange,
getAttachments, getAttachments,
}; };
} }
@@ -158,6 +183,7 @@ export function useChatInputState({
setAttachments, setAttachments,
addFiles, addFiles,
handleRemoveAttachment, handleRemoveAttachment,
handleDeliveryModeChange,
getAttachments, getAttachments,
} = useAttachments(sessionId); } = useAttachments(sessionId);
@@ -225,6 +251,7 @@ export function useChatInputState({
label: `Image (${formatBytes(att.size)})`, label: `Image (${formatBytes(att.size)})`,
attachment: att, attachment: att,
onRemove: () => handleRemoveAttachment(att.id), onRemove: () => handleRemoveAttachment(att.id),
onDeliveryModeChange: (mode) => handleDeliveryModeChange(att.id, mode),
} as ImageContextItem) } as ImageContextItem)
: ({ : ({
kind: "file-attachment" as const, kind: "file-attachment" as const,
@@ -235,7 +262,7 @@ export function useChatInputState({
} as FileAttachmentContextItem), } as FileAttachmentContextItem),
); );
return [...contextItems, ...attachmentItems]; return [...contextItems, ...attachmentItems];
}, [contextItems, attachments, handleRemoveAttachment]); }, [contextItems, attachments, handleRemoveAttachment, handleDeliveryModeChange]);
// prettier-ignore // prettier-ignore
return { value, attachments, inputRef, addFiles, handleChange, handleSubmit, allItems, getAttachments }; return { value, attachments, inputRef, addFiles, handleChange, handleSubmit, allItems, getAttachments };
@@ -41,6 +41,7 @@ const ATTACHMENT: FileAttachment = {
fileName: "notes.txt", fileName: "notes.txt",
size: 42, size: 42,
isImage: false, isImage: false,
deliveryMode: "path",
}; };
const TASK_ID = "task-1"; const TASK_ID = "task-1";
const PROFILE_ID = "profile-a"; const PROFILE_ID = "profile-a";
+36 -1
View File
@@ -84,6 +84,8 @@ type CreateTaskOpts = {
repositories?: Array<{ repository_id: string; base_branch?: string; checkout_branch?: string }>; repositories?: Array<{ repository_id: string; base_branch?: string; checkout_branch?: string }>;
plan_mode?: boolean; plan_mode?: boolean;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
parent_id?: string;
attachments?: MessageAttachmentInput[];
}; };
function buildTaskMetadata(opts: CreateTaskOpts): Record<string, unknown> | undefined { function buildTaskMetadata(opts: CreateTaskOpts): Record<string, unknown> | undefined {
@@ -112,11 +114,20 @@ function buildCreateTaskBody(
"repositories", "repositories",
opts?.repositories ?? opts?.repository_ids?.map((id) => ({ repository_id: id })), opts?.repositories ?? opts?.repository_ids?.map((id) => ({ repository_id: id })),
); );
setIf(body, "attachments", opts?.attachments);
if (opts?.plan_mode) body.plan_mode = true; if (opts?.plan_mode) body.plan_mode = true;
setIf(body, "parent_id", opts?.parent_id); setIf(body, "parent_id", opts?.parent_id);
return body; return body;
} }
type MessageAttachmentInput = {
type: string;
data: string;
mime_type: string;
name?: string;
delivery_mode?: "prompt" | "path";
};
type OptionalAgentTaskOpts = { type OptionalAgentTaskOpts = {
workflow_id?: string; workflow_id?: string;
workflow_step_id?: string; workflow_step_id?: string;
@@ -126,6 +137,7 @@ type OptionalAgentTaskOpts = {
executor_profile_id?: string; executor_profile_id?: string;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
parent_id?: string; parent_id?: string;
attachments?: MessageAttachmentInput[];
}; };
/** `repositories` (with per-entry branches) takes precedence over the shorthand /** `repositories` (with per-entry branches) takes precedence over the shorthand
@@ -147,6 +159,7 @@ function buildOptionalAgentTaskFields(opts?: OptionalAgentTaskOpts): Record<stri
setIf(fields, "executor_profile_id", opts.executor_profile_id); setIf(fields, "executor_profile_id", opts.executor_profile_id);
setIf(fields, "metadata", opts.metadata); setIf(fields, "metadata", opts.metadata);
setIf(fields, "parent_id", opts.parent_id); setIf(fields, "parent_id", opts.parent_id);
setIf(fields, "attachments", opts.attachments);
return fields; return fields;
} }
@@ -274,6 +287,7 @@ export class ApiClient {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
/** Parent task ID for subtasks. */ /** Parent task ID for subtasks. */
parent_id?: string; parent_id?: string;
attachments?: MessageAttachmentInput[];
}, },
): Promise<CreateTaskResponse> { ): Promise<CreateTaskResponse> {
return this.request("POST", "/api/v1/tasks", buildCreateTaskBody(workspaceId, title, opts)); return this.request("POST", "/api/v1/tasks", buildCreateTaskBody(workspaceId, title, opts));
@@ -415,6 +429,7 @@ export class ApiClient {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
/** Parent task ID for subtasks. */ /** Parent task ID for subtasks. */
parent_id?: string; parent_id?: string;
attachments?: MessageAttachmentInput[];
}, },
): Promise<CreateTaskResponse> { ): Promise<CreateTaskResponse> {
return this.request("POST", "/api/v1/tasks", { return this.request("POST", "/api/v1/tasks", {
@@ -1311,11 +1326,31 @@ export class ApiClient {
* Use after the auto-started agent finishes so the session is in a state * Use after the auto-started agent finishes so the session is in a state
* that accepts new prompts. * that accepts new prompts.
*/ */
async addUserMessage(taskId: string, sessionId: string, content: string): Promise<void> { async addUserMessage(
taskId: string,
sessionId: string,
content: string,
attachments?: MessageAttachmentInput[],
): Promise<void> {
await this.wsRequest("message.add", { await this.wsRequest("message.add", {
task_id: taskId, task_id: taskId,
session_id: sessionId, session_id: sessionId,
content, content,
attachments,
});
}
async queueMessage(
taskId: string,
sessionId: string,
content: string,
attachments?: MessageAttachmentInput[],
): Promise<void> {
await this.wsRequest("message.queue.add", {
task_id: taskId,
session_id: sessionId,
content,
attachments,
}); });
} }
@@ -0,0 +1,124 @@
import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { openTaskSession, waitForLatestSessionDone } from "../../helpers/session";
type PersistedAttachment = {
type?: string;
name?: string;
mime_type?: string;
delivery_mode?: string;
};
async function waitForUserAttachment(
apiClient: ApiClient,
sessionId: string,
content: string,
): Promise<PersistedAttachment> {
await expect
.poll(
async () => {
const { messages } = await apiClient.listSessionMessages(sessionId);
const message = messages.find(
(m) => m.author_type === "user" && m.content.includes(content),
);
const attachments = message?.metadata?.attachments;
return Array.isArray(attachments) ? attachments.length : 0;
},
{ timeout: 15_000, message: `Wait for persisted attachment on "${content}"` },
)
.toBeGreaterThan(0);
const { messages } = await apiClient.listSessionMessages(sessionId);
const message = messages.find((m) => m.author_type === "user" && m.content.includes(content));
const attachments = message?.metadata?.attachments;
if (!Array.isArray(attachments)) throw new Error("Persisted user message has no attachments");
return attachments[0] as PersistedAttachment;
}
test.describe("chat attachment delivery mode", () => {
test("shows a Prompt/File selector for supported images and sends unsupported files by path", async ({
testPage,
apiClient,
seedData,
}, testInfo) => {
test.setTimeout(90_000);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Attachment delivery selector",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for initial attachment task");
const session = await openTaskSession(testPage, task.id);
await session.waitForChatIdle({ timeout: 30_000 });
fs.mkdirSync(testInfo.outputDir, { recursive: true });
const imagePath = path.join(testInfo.outputDir, "tiny.png");
fs.writeFileSync(
imagePath,
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/6X3pZQAAAAASUVORK5CYII=",
"base64",
),
);
await testPage.locator('input[type="file"]').setInputFiles(imagePath);
const imageChip = testPage.getByText(/Image \(/).first();
await expect(imageChip).toBeVisible({ timeout: 10_000 });
await imageChip.hover();
const promptButton = testPage.getByTestId("attachment-delivery-prompt");
const pathButton = testPage.getByTestId("attachment-delivery-path");
await expect(promptButton).toBeVisible({ timeout: 10_000 });
await expect(pathButton).toBeVisible();
await expect(promptButton).toHaveAttribute("data-selected", "true");
await pathButton.click();
await expect(pathButton).toHaveAttribute("data-selected", "true");
await session.sendMessageViaButton("send image as file path");
const imageAttachment = await waitForUserAttachment(
apiClient,
task.session_id,
"send image as file path",
);
expect(imageAttachment).toMatchObject({
type: "image",
name: "tiny.png",
mime_type: "image/png",
delivery_mode: "path",
});
await session.waitForChatIdle({ timeout: 30_000 });
const textPath = path.join(testInfo.outputDir, "notes.txt");
fs.writeFileSync(textPath, "plain text attachment");
await testPage.locator('input[type="file"]').setInputFiles(textPath);
await expect(testPage.getByText("notes.txt", { exact: true })).toBeVisible({
timeout: 10_000,
});
await testPage.getByText("notes.txt", { exact: true }).hover();
await expect(testPage.getByTestId("attachment-delivery-prompt")).toHaveCount(0);
await expect(testPage.getByTestId("attachment-delivery-path")).toHaveCount(0);
await session.sendMessageViaButton("send unsupported file");
const textAttachment = await waitForUserAttachment(
apiClient,
task.session_id,
"send unsupported file",
);
expect(textAttachment).toMatchObject({
type: "resource",
name: "notes.txt",
mime_type: "text/plain",
delivery_mode: "path",
});
});
});
+121 -1
View File
@@ -1,5 +1,10 @@
import { test, expect } from "../../fixtures/ssh-test-base"; import { test, expect } from "../../fixtures/ssh-test-base";
import { listRemoteDir, readRemoteFile, remotePathExists } from "../../helpers/ssh"; import {
execInContainer,
listRemoteDir,
readRemoteFile,
remotePathExists,
} from "../../helpers/ssh";
import { waitForLatestSessionDone } from "../../helpers/session"; import { waitForLatestSessionDone } from "../../helpers/session";
/** /**
@@ -165,6 +170,121 @@ test.describe("ssh executor — task launch", () => {
expect(row!.remote_agentctl_port).toBeGreaterThan(0); expect(row!.remote_agentctl_port).toBeGreaterThan(0);
}); });
test("uploads path-mode attachments into the remote executor workspace", async ({
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const content = "remote attachment bytes";
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"H6 attachment upload",
seedData.agentProfileId,
{
description: "Inspect the attached file.",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.sshExecutorProfileId,
attachments: [
{
type: "resource",
data: Buffer.from(content, "utf8").toString("base64"),
mime_type: "text/plain",
name: "remote-note.txt",
delivery_mode: "path",
},
],
},
);
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for attachment upload");
await expect
.poll(
async () => {
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
return sessions.find((s) => s.task_id === task.id)?.remote_task_dir ?? "";
},
{ timeout: 60_000, message: "Wait for SSH session row" },
)
.not.toBe("");
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
const row = sessions.find((s) => s.task_id === task.id);
expect(row?.remote_task_dir).toBeTruthy();
const savedPath = execInContainer(seedData.sshTarget, [
"sh",
"-c",
`find ${JSON.stringify(row!.remote_task_dir)}/.kandev/attachments -name remote-note.txt -type f -print -quit`,
]).trim();
expect(savedPath).toContain("/.kandev/attachments/");
expect(readRemoteFile(seedData.sshTarget, savedPath)).toBe(content);
});
test("uploads path-mode attachments queued while the remote task is running", async ({
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const content = "remote follow-up attachment bytes";
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"H6 running attachment upload",
seedData.agentProfileId,
{
description: "/slow 20s",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.sshExecutorProfileId,
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await apiClient.queueMessage(task.id, task.session_id, "Inspect the follow-up file.", [
{
type: "resource",
data: Buffer.from(content, "utf8").toString("base64"),
mime_type: "text/plain",
name: "remote-followup.txt",
delivery_mode: "path",
},
]);
await expect
.poll(
async () => {
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
return sessions.find((s) => s.task_id === task.id)?.remote_task_dir ?? "";
},
{ timeout: 60_000, message: "Wait for SSH session row" },
)
.not.toBe("");
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
const row = sessions.find((s) => s.task_id === task.id);
const taskDir = row?.remote_task_dir;
expect(taskDir).toBeTruthy();
const findAttachment = () =>
execInContainer(seedData.sshTarget, [
"sh",
"-c",
`find ${JSON.stringify(taskDir)}/.kandev/attachments -name remote-followup.txt -type f -print -quit 2>/dev/null || true`,
]).trim();
await expect
.poll(findAttachment, {
timeout: 120_000,
message: "Wait for queued attachment to upload remotely",
})
.not.toBe("");
const attachmentPath = findAttachment();
expect(attachmentPath).toContain("/.kandev/attachments/");
expect(readRemoteFile(seedData.sshTarget, attachmentPath)).toBe(content);
});
test("stopping the session cleans up the session runtime dir but leaves the task dir", async ({ test("stopping the session cleans up the session runtime dir but leaves the task dir", async ({
apiClient, apiClient,
seedData, seedData,
@@ -17,6 +17,8 @@ export type MessageAttachment = {
type: string; type: string;
data: string; data: string;
mime_type: string; mime_type: string;
name?: string;
delivery_mode?: "prompt" | "path";
}; };
/** Selectors over the queue slice for one session. */ /** Selectors over the queue slice for one session. */
+1
View File
@@ -227,6 +227,7 @@ export function useMessageHandler({
data: att.data, data: att.data,
mime_type: att.mime_type, mime_type: att.mime_type,
name: att.name, name: att.name,
delivery_mode: att.delivery_mode,
})); }));
await queue(taskId, finalMessage, modelToSend, planModeEnabled, queueAttachments); await queue(taskId, finalMessage, modelToSend, planModeEnabled, queueAttachments);
return; return;
+7 -1
View File
@@ -71,7 +71,13 @@ export async function createTask(
executor_id?: string; executor_id?: string;
executor_profile_id?: string; executor_profile_id?: string;
plan_mode?: boolean; plan_mode?: boolean;
attachments?: Array<{ type: string; data: string; mime_type: string; name?: string }>; attachments?: Array<{
type: string;
data: string;
mime_type: string;
name?: string;
delivery_mode?: "prompt" | "path";
}>;
parent_id?: string; parent_id?: string;
workspace_path?: string; workspace_path?: string;
priority?: string; priority?: string;
+14 -2
View File
@@ -68,7 +68,13 @@ export type QueueMessageParams = {
content: string; content: string;
model?: string; model?: string;
plan_mode?: boolean; plan_mode?: boolean;
attachments?: Array<{ type: string; data: string; mime_type: string }>; attachments?: Array<{
type: string;
data: string;
mime_type: string;
name?: string;
delivery_mode?: "prompt" | "path";
}>;
user_id?: string; user_id?: string;
}; };
@@ -140,7 +146,13 @@ export async function updateQueuedMessage(params: {
session_id: string; session_id: string;
entry_id: string; entry_id: string;
content: string; content: string;
attachments?: Array<{ type: string; data: string; mime_type: string }>; attachments?: Array<{
type: string;
data: string;
mime_type: string;
name?: string;
delivery_mode?: "prompt" | "path";
}>;
user_id?: string; user_id?: string;
}): Promise<{ entry_id: string }> { }): Promise<{ entry_id: string }> {
const client = getWebSocketClient(); const client = getWebSocketClient();
+32
View File
@@ -5,6 +5,7 @@ import {
getGlobalSidebarWidth, getGlobalSidebarWidth,
markPRClosedBannerDismissed, markPRClosedBannerDismissed,
markPRMergedBannerDismissed, markPRMergedBannerDismissed,
restoreAttachmentPreview,
setGlobalSidebarWidth, setGlobalSidebarWidth,
wasPRClosedBannerDismissed, wasPRClosedBannerDismissed,
wasPRMergedBannerDismissed, wasPRMergedBannerDismissed,
@@ -103,3 +104,34 @@ describe("global sidebar width storage", () => {
expect(getGlobalSidebarWidth()).toBe(320); expect(getGlobalSidebarWidth()).toBe(320);
}); });
}); });
describe("chat draft attachment storage", () => {
it("normalizes invalid restored image delivery modes to prompt", () => {
const restored = restoreAttachmentPreview({
id: "att-1",
data: "abc",
mimeType: "image/png",
fileName: "shot.png",
size: 3,
isImage: true,
deliveryMode: "inline" as "prompt",
});
expect(restored.deliveryMode).toBe("prompt");
expect(restored.preview).toBe("data:image/png;base64,abc");
});
it("normalizes invalid restored file delivery modes to path", () => {
const restored = restoreAttachmentPreview({
id: "att-2",
data: "abc",
mimeType: "application/pdf",
fileName: "doc.pdf",
size: 3,
isImage: false,
deliveryMode: "inline" as "path",
});
expect(restored.deliveryMode).toBe("path");
});
});
+18 -4
View File
@@ -542,6 +542,7 @@ type StoredFileAttachment = {
fileName: string; fileName: string;
size: number; size: number;
isImage: boolean; isImage: boolean;
deliveryMode?: "prompt" | "path";
}; };
export function getChatDraftText(sessionId: string): string { export function getChatDraftText(sessionId: string): string {
@@ -576,6 +577,13 @@ export function getChatDraftAttachments(sessionId: string): StoredFileAttachment
); );
} }
function normalizeAttachmentDeliveryMode(
value: unknown,
fallback: "prompt" | "path",
): "prompt" | "path" {
return value === "prompt" || value === "path" ? value : fallback;
}
export function setChatDraftAttachments( export function setChatDraftAttachments(
sessionId: string, sessionId: string,
attachments: Array<{ attachments: Array<{
@@ -585,6 +593,7 @@ export function setChatDraftAttachments(
fileName: string; fileName: string;
size: number; size: number;
isImage: boolean; isImage: boolean;
deliveryMode?: "prompt" | "path";
preview?: string; preview?: string;
}>, }>,
): void { ): void {
@@ -593,13 +602,14 @@ export function setChatDraftAttachments(
} else { } else {
// Strip `preview` to halve storage cost — reconstructed on load for images // Strip `preview` to halve storage cost — reconstructed on load for images
const stored: StoredFileAttachment[] = attachments.map( const stored: StoredFileAttachment[] = attachments.map(
({ id, data, mimeType, fileName, size, isImage }) => ({ ({ id, data, mimeType, fileName, size, isImage, deliveryMode }) => ({
id, id,
data, data,
mimeType, mimeType,
fileName, fileName,
size, size,
isImage, isImage,
deliveryMode,
}), }),
); );
setSessionStorage(`${CHAT_DRAFT_ATTACHMENTS_KEY}.${sessionId}`, stored); setSessionStorage(`${CHAT_DRAFT_ATTACHMENTS_KEY}.${sessionId}`, stored);
@@ -611,11 +621,15 @@ export function setChatDraftAttachments(
*/ */
export function restoreAttachmentPreview( export function restoreAttachmentPreview(
att: StoredFileAttachment, att: StoredFileAttachment,
): StoredFileAttachment & { preview?: string } { ): StoredFileAttachment & { deliveryMode: "prompt" | "path"; preview?: string } {
if (att.isImage) { if (att.isImage) {
return { ...att, preview: `data:${att.mimeType};base64,${att.data}` }; return {
...att,
deliveryMode: normalizeAttachmentDeliveryMode(att.deliveryMode, "prompt"),
preview: `data:${att.mimeType};base64,${att.data}`,
};
} }
return att; return { ...att, deliveryMode: normalizeAttachmentDeliveryMode(att.deliveryMode, "path") };
} }
export function getChatInputHeight(sessionId: string): number | null { export function getChatInputHeight(sessionId: string): number | null {
@@ -13,6 +13,7 @@ export type MessageAttachment = {
data: string; data: string;
mime_type: string; mime_type: string;
name?: string; name?: string;
delivery_mode?: "prompt" | "path";
}; };
export type LaunchSessionRequest = { export type LaunchSessionRequest = {
+7 -1
View File
@@ -102,7 +102,13 @@ export type QueuedMessage = {
content: string; content: string;
model?: string; model?: string;
plan_mode: boolean; plan_mode: boolean;
attachments?: Array<{ type: string; data: string; mime_type: string }>; attachments?: Array<{
type: string;
data: string;
mime_type: string;
name?: string;
delivery_mode?: "prompt" | "path";
}>;
metadata?: QueuedMessageMetadata; metadata?: QueuedMessageMetadata;
queued_at: string; queued_at: string;
queued_by?: string; queued_by?: string;
+1
View File
@@ -44,6 +44,7 @@ export type PlanCommentContextItem = ContextItemBase & {
export type ImageContextItem = ContextItemBase & { export type ImageContextItem = ContextItemBase & {
kind: "image"; kind: "image";
attachment: FileAttachment; attachment: FileAttachment;
onDeliveryModeChange?: (mode: "prompt" | "path") => void;
}; };
export type FileAttachmentContextItem = ContextItemBase & { export type FileAttachmentContextItem = ContextItemBase & {
+12
View File
@@ -1,5 +1,17 @@
# Screenshots # Screenshots
## Mobile
<table>
<tr>
<td align="center"><strong>Mobile Chat</strong><br><img src="screenshots/mobile-chat.webp" alt="Mobile Chat" width="240"></td>
<td align="center"><strong>Mobile Changes</strong><br><img src="screenshots/mobile-changes.webp" alt="Mobile Changes" width="240"></td>
<td align="center"><strong>Mobile Diff Review</strong><br><img src="screenshots/mobile-diff.webp" alt="Mobile Diff Review" width="240"></td>
</tr>
</table>
## Desktop
<table> <table>
<tr> <tr>
<td align="center"><strong>Kanban</strong><br><img src="screenshots/kanban.png" alt="Kanban"></td> <td align="center"><strong>Kanban</strong><br><img src="screenshots/kanban.png" alt="Kanban"></td>
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB